Skip to content

feat(security): HMAC signing infrastructure for proxy endpoints#699

Merged
harlan-zw merged 14 commits intomainfrom
feat/proxy-url-signing
Apr 11, 2026
Merged

feat(security): HMAC signing infrastructure for proxy endpoints#699
harlan-zw merged 14 commits intomainfrom
feat/proxy-url-signing

Conversation

@harlan-zw
Copy link
Copy Markdown
Collaborator

🔗 Linked issue

Related to #594

❓ Type of change

  • 📖 Documentation
  • 🐞 Bug fix
  • 👌 Enhancement
  • ✨ New feature
  • 🧹 Chore
  • ⚠️ Breaking change

📚 Description

Proxy endpoints like Google Static Maps and Geocode inject a server-side API key and forward arbitrary requests to third-party services. Without signing, anyone can call these endpoints directly and burn the site owner's API quota. This PR adds the signing infrastructure so individual endpoints can opt in.

What's included:

  • Sign/verify primitives (runtime/server/utils/sign.ts): canonical query serialization (sorted keys, sig-stripped, URL-encoded), HMAC-SHA256 with 16-char hex signatures, constant-time comparison
  • withSigning wrapper (runtime/server/utils/withSigning.ts): opt-in middleware that returns 403 on missing/invalid signatures, 500 if secret not configured
  • Secret management (module.ts): NUXT_SCRIPTS_PROXY_SECRET env var, scripts.security.secret config, dev auto-generates to .env, prod throws if missing when signed endpoints are registered
  • /_scripts/sign endpoint (runtime/server/sign-proxy.ts): for reactive client-side URL updates (e.g. Google Static Maps recomputing size on mount); same-origin check + per-IP rate limiting (60/min)
  • CLI (src/cli.ts + bin/cli.mjs): npx @nuxt/scripts generate-secret for explicit secret management
  • requiresSigning flag on RegistryScriptServerHandler so endpoints can declare they need signing
  • 23 unit tests covering canonicalization, roundtrip verification, tamper rejection, cross-endpoint isolation

Not included (follow-up PRs):

  • Wiring requiresSigning: true on actual handlers (google-static-maps, geocode, gravatar, embed image proxies)
  • Updating components to call buildSignedProxyUrl at SSR time
  • Hardening createImageProxyHandler (streaming size limit, DNS + private-IP check)
  • Body-size cap on /_scripts/p/** analytics reverse proxy

Adds the foundation for locking down proxy endpoints (Google Maps, Gravatar,
embed image proxies) against cost/quota abuse. Signed URLs are generated
server-side during SSR/prerender and verified at the endpoint so only
legitimate requests reach upstream APIs.

- sign/verify primitives with canonical query form and constant-time compare
- withSigning handler wrapper (opt-in per endpoint via requiresSigning flag)
- Secret management: env var > config > dev auto-generate to .env
- /_scripts/sign endpoint for reactive client-side URLs (origin + rate-limited)
- nuxt-scripts CLI with generate-secret command for explicit secret setup

No existing endpoints are wired to signing yet; this PR is infrastructure only.
@vercel
Copy link
Copy Markdown
Contributor

vercel bot commented Apr 10, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
scripts-playground Error Error Apr 11, 2026 3:02am

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 10, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@nuxt/scripts@699

commit: 51d3c14

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 10, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds HMAC-based signing to the Nuxt Scripts package and a small CLI for secret management. New utilities canonicalize query parameters, compute/truncate HMAC-SHA256 signatures, build/verify signed proxy URLs, and generate/verify time-limited page tokens. Module changes resolve a proxy secret from config/env or auto-generate in dev (with optional .env persistence) and expose it via runtimeConfig. A withSigning wrapper enforces verification for handlers; multiple proxy/embed handlers and registry entries were marked as requiring signing. Tests for signing/token logic and package CLI/build/package.json updates were added.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 56.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(security): HMAC signing infrastructure for proxy endpoints' accurately captures the main change: adding HMAC-based signing infrastructure to protect proxy endpoints.
Description check ✅ Passed The description provides relevant context about the feature, explains the purpose (preventing API quota abuse), lists included infrastructure components, and notes follow-up work, all directly related to the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/proxy-url-signing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
test/unit/sign.test.ts (1)

39-47: Minor: Test description casing.

Test descriptions at lines 39 and 44 have unusual casing: "uRL-encodes" and "jSON-stringifies". Consider "URL-encodes" and "JSON-stringifies" for consistency.

📝 Suggested fix
-  it('uRL-encodes keys and values', () => {
+  it('URL-encodes keys and values', () => {
     expect(canonicalizeQuery({ 'q': 'hello world', 'a+b': 'c&d' }))
       .toBe('a%2Bb=c%26d&q=hello%20world')
   })

-  it('jSON-stringifies object values for stable comparison', () => {
+  it('JSON-stringifies object values for stable comparison', () => {
     expect(canonicalizeQuery({ style: { color: 'red' } }))
       .toBe('style=%7B%22color%22%3A%22red%22%7D')
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/sign.test.ts` around lines 39 - 47, Update the two test
descriptions in sign.test.ts to use correct casing: change "uRL-encodes keys and
values" to "URL-encodes keys and values" and "jSON-stringifies object values for
stable comparison" to "JSON-stringifies object values for stable comparison";
these are the it(...) descriptions for the tests asserting canonicalizeQuery
behavior, so edit the string arguments passed to the it() calls that reference
canonicalizeQuery accordingly.
packages/script/src/runtime/server/sign-proxy.ts (1)

110-110: Remove unnecessary as any cast on storage options.

The setItem method from useStorage() (unstorage) accepts opts?: TransactionOptions where TransactionOptions = Record<string, any>. The object { ttl: 120 } already conforms to this type, making the as any cast redundant and masking potential type issues. Remove the cast.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/script/src/runtime/server/sign-proxy.ts` at line 110, The call to
storage.setItem(rateLimitKey, currentCount + 1, { ttl: 120 } as any) uses an
unnecessary and masking cast; remove the cast so the options object is passed
with its natural type. Update the invocation of storage.setItem in sign-proxy.ts
(referencing storage.setItem and rateLimitKey) to await
storage.setItem(rateLimitKey, currentCount + 1, { ttl: 120 }) and ensure no
other code relies on the as any cast.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/script/src/module.ts`:
- Around line 119-120: The current PROXY_SECRET_ENV_VALUE_RE
/^NUXT_SCRIPTS_PROXY_SECRET=(.+)$/m will capture inline comments (e.g. "abc #
comment"); update the extraction so the captured value stops before a "#" or
end-of-line and still allows trimming. Replace or modify
PROXY_SECRET_ENV_VALUE_RE to capture only characters up to a "#" or line ending
(for example using a pattern like capturing [^#\r\n]*), and keep the existing
.trim() usage when reading the value; also ensure PROXY_SECRET_ENV_LINE_RE
remains consistent with the new value-detection logic.

In `@packages/script/src/runtime/server/sign-proxy.ts`:
- Around line 96-110: The rate limiter currently trusts X-Forwarded-For via
getRequestIP(event, { xForwardedFor: true }), which can be spoofed unless behind
a trusted proxy; make this behavior explicit and configurable: add a runtime
option (e.g., trustXForwardedFor or trustedProxy) to the sign-proxy
configuration and use that flag when calling getRequestIP instead of the
hard-coded { xForwardedFor: true }, and update the threat-model/docs text to
state the trusted-proxy assumption; keep existing defaults to preserve current
deployments (or set default to false) and ensure references to
RATE_LIMIT_KEY_PREFIX, RATE_LIMIT_PER_MINUTE, and useStorage remain unchanged.

In `@packages/script/src/runtime/server/utils/sign.ts`:
- Around line 137-151: The doc comment on constantTimeEqual is misleading: it
states the loop runs over the longer length to avoid leaking length, but the
implementation actually early-returns when lengths differ. Update the comment
for the function constantTimeEqual to accurately describe behavior (e.g., state
that it first checks lengths and returns false on mismatch, then performs a
constant-time comparison over a.length), and optionally mention that callers
like verifyProxyRequest and signProxyUrl ensure fixed-length inputs so the
length check is normally redundant.

---

Nitpick comments:
In `@packages/script/src/runtime/server/sign-proxy.ts`:
- Line 110: The call to storage.setItem(rateLimitKey, currentCount + 1, { ttl:
120 } as any) uses an unnecessary and masking cast; remove the cast so the
options object is passed with its natural type. Update the invocation of
storage.setItem in sign-proxy.ts (referencing storage.setItem and rateLimitKey)
to await storage.setItem(rateLimitKey, currentCount + 1, { ttl: 120 }) and
ensure no other code relies on the as any cast.

In `@test/unit/sign.test.ts`:
- Around line 39-47: Update the two test descriptions in sign.test.ts to use
correct casing: change "uRL-encodes keys and values" to "URL-encodes keys and
values" and "jSON-stringifies object values for stable comparison" to
"JSON-stringifies object values for stable comparison"; these are the it(...)
descriptions for the tests asserting canonicalizeQuery behavior, so edit the
string arguments passed to the it() calls that reference canonicalizeQuery
accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0bfb584a-a88c-48fb-a41f-e28da3302414

📥 Commits

Reviewing files that changed from the base of the PR and between 3fa134f and e8434f1.

📒 Files selected for processing (10)
  • packages/script/bin/cli.mjs
  • packages/script/build.config.ts
  • packages/script/package.json
  • packages/script/src/cli.ts
  • packages/script/src/module.ts
  • packages/script/src/runtime/server/sign-proxy.ts
  • packages/script/src/runtime/server/utils/sign.ts
  • packages/script/src/runtime/server/utils/withSigning.ts
  • packages/script/src/runtime/types.ts
  • test/unit/sign.test.ts

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds opt-in HMAC signing infrastructure to protect proxy endpoints (that inject server-side API keys / forward third-party requests) from direct quota-abuse by requiring server-generated signed URLs.

Changes:

  • Introduces signing primitives (canonicalizeQuery, HMAC signing, request verification) plus a withSigning() middleware wrapper for proxy handlers.
  • Adds secret resolution/management in the Nuxt module (env/config + dev auto-generation) and publishes signable route allowlists to runtime config.
  • Adds a /_scripts/sign server endpoint and an npx @nuxt/scripts generate-secret CLI for obtaining signatures without exposing the secret.

Reviewed changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
test/unit/sign.test.ts Adds unit tests for query canonicalization, signing determinism, and constant-time comparison.
packages/script/src/runtime/types.ts Adds requiresSigning?: boolean flag to server handler metadata.
packages/script/src/runtime/server/utils/withSigning.ts Adds middleware wrapper enforcing signature verification on handlers.
packages/script/src/runtime/server/utils/sign.ts Implements canonicalization, signing, verification, and constant-time compare utilities.
packages/script/src/runtime/server/sign-proxy.ts Adds /_scripts/sign endpoint with allowlist, same-origin check, and rate limiting.
packages/script/src/module.ts Adds proxy-secret resolution (env/config/dev generation), signable route collection, and /sign handler registration.
packages/script/src/cli.ts Adds CLI implementation for generate-secret.
packages/script/package.json Publishes a nuxt-scripts bin entry and includes bin/ in package files.
packages/script/build.config.ts Includes CLI entry in build.
packages/script/bin/cli.mjs Adds Node entrypoint that loads the built CLI.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@harlan-zw
Copy link
Copy Markdown
Collaborator Author

Code review

Found 1 issue:

  1. Em dashes used throughout new files, violating the writing style rule in .claude/CLAUDE.md: "Never use em dashes (—) or hyphens as dashes (-) in writing. Restructure sentences using commas, semicolons, colons, or separate sentences instead." Approximately 10 instances across cli.ts, module.ts, sign-proxy.ts, and withSigning.ts, in both code comments and CLI output strings.

*
* Currently hosts a single command — `generate-secret` — which produces a
* cryptographically random HMAC secret for `NUXT_SCRIPTS_PROXY_SECRET`. This
* is an alternative to letting the module auto-write a secret into `.env`,
* for users who want explicit control (e.g. teams that commit secrets to a

*
* Most proxy URLs are built server-side during SSR/prerender and embedded in
* HTML with a signature already attached — the client just uses them verbatim.
* But some components rebuild their URLs reactively on the client after mount

return { secret: configSecret, ephemeral: false, source: 'config' }
const envSecret = process.env[PROXY_SECRET_ENV_KEY]

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Remove the /_scripts/sign endpoint (rate limiter, origin check, server-side
storage) and replace it with stateless page tokens that the server embeds
in the SSR payload.

Page tokens are HMAC-signed timestamps valid for 1 hour. The client attaches
_pt + _ts params to proxy requests instead of needing a signing round-trip.
verifyProxyRequest now accepts either a URL signature (exact params, from SSR)
or a page token (any params, time-limited, from client).

This removes the rate limiter from the module. Rate limiting is the site
owner's concern, not the module's.
All 10 server handlers now enforce HMAC signature verification:
- google-static-maps-proxy, google-maps-geocode-proxy, gravatar-proxy
- x-embed, bluesky-embed, instagram-embed
- createImageProxyHandler (covers x-embed-image, bluesky-embed-image,
  instagram-embed-image, instagram-embed-asset)

Registry entries updated with requiresSigning: true so the module
enforces NUXT_SCRIPTS_PROXY_SECRET in production when any of these
scripts are enabled.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/script/src/runtime/server/instagram-embed.ts (1)

190-335: ⚠️ Potential issue | 🔴 Critical

Blocker: returned Instagram asset/image proxy URLs are now unsigned but downstream routes require signatures.

After enabling signed-only proxy routes, this handler still emits plain /_scripts/embed/instagram-image?... and /_scripts/embed/instagram-asset?... URLs. Browser follow-up requests will fail with 403.
Please sign these generated URLs server-side (or temporarily stop requiring signing on those two routes until signed URL generation is wired).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/script/src/runtime/server/instagram-embed.ts` around lines 190 -
335, The handler emits plain proxy URLs (e.g.,
`${prefix}/embed/instagram-asset?url=...` and image URLs rewritten via
rewriteUrl) which will 403 because downstream proxy routes now require signed
URLs; fix by signing those generated URLs server-side before inserting into
HTML/CSS: locate where prefix is used to build asset URLs (the RSRC_RE
replacement producing `.../embed/instagram-asset?url=...`) and the calls to
rewriteUrl/rewriteUrlsInText that produce `.../embed/instagram-image?...`, and
replace the plain `${prefix}/embed/...` strings with calls to your signing
utility (e.g., signUrl/signPath/getSignedUrl) so the handler returns signed
proxy links; ensure you import/use the same signing helper used by withSigning
so all generated asset/image URLs include valid signatures.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/script/src/runtime/server/instagram-embed.ts`:
- Around line 190-335: The handler emits plain proxy URLs (e.g.,
`${prefix}/embed/instagram-asset?url=...` and image URLs rewritten via
rewriteUrl) which will 403 because downstream proxy routes now require signed
URLs; fix by signing those generated URLs server-side before inserting into
HTML/CSS: locate where prefix is used to build asset URLs (the RSRC_RE
replacement producing `.../embed/instagram-asset?url=...`) and the calls to
rewriteUrl/rewriteUrlsInText that produce `.../embed/instagram-image?...`, and
replace the plain `${prefix}/embed/...` strings with calls to your signing
utility (e.g., signUrl/signPath/getSignedUrl) so the handler returns signed
proxy links; ensure you import/use the same signing helper used by withSigning
so all generated asset/image URLs include valid signatures.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 86ce41b5-011e-4cbd-80c8-82656b32a59e

📥 Commits

Reviewing files that changed from the base of the PR and between d13999a and c3a6997.

📒 Files selected for processing (8)
  • packages/script/src/registry.ts
  • packages/script/src/runtime/server/bluesky-embed.ts
  • packages/script/src/runtime/server/google-maps-geocode-proxy.ts
  • packages/script/src/runtime/server/google-static-maps-proxy.ts
  • packages/script/src/runtime/server/gravatar-proxy.ts
  • packages/script/src/runtime/server/instagram-embed.ts
  • packages/script/src/runtime/server/utils/image-proxy.ts
  • packages/script/src/runtime/server/x-embed.ts

withSigning now delegates to the inner handler without verification
when NUXT_SCRIPTS_PROXY_SECRET is not set. This avoids breaking
existing users who upgrade before components emit signed URLs.

Signing enforcement activates automatically once a secret is set.
@harlan-zw
Copy link
Copy Markdown
Collaborator Author

Code review

Found 2 issues:

  1. module.ts fatal throw breaks CI and nuxt prepare. The guard anyHandlerRequiresSigning && !proxySecretResolved?.secret && !nuxt.options.dev fires during nuxt prepare (which runs in non-dev mode) when no NUXT_SCRIPTS_PROXY_SECRET is set. Since all 10 registry handlers now have requiresSigning: true, any fixture or project enabling these scripts fails at prepare time. This contradicts the withSigning pass-through behavior (which is designed to be non-breaking). The throw should be gated on whether the secret was explicitly requested (e.g. config.security?.secret was set), or downgraded to a warning.

https://github.com/nuxt/scripts/blob/deacf59ce9a16ff9296562391877a1a98c5cef6c/packages/script/src/module.ts#L876-L884

  1. resolveProxySecret runs unconditionally before anyHandlerRequiresSigning is determined. In dev, it auto-generates a secret and writes NUXT_SCRIPTS_PROXY_SECRET to .env even when zero proxy endpoints are enabled. Only 5 of 38 registry scripts have serverHandlers; users selecting only client-side scripts (analytics, tracking) get an unexpected env var in their .env. The call should move after the handler registration loop and only execute when anyHandlerRequiresSigning is true.

// and persists one into `.env` in dev so users don't hit friction on first run.
const proxySecretResolved = resolveProxySecret(
nuxt.options.rootDir,
!!nuxt.options.dev,
config.security?.secret,
config.security?.autoGenerateSecret !== false,
)
if (proxySecretResolved?.source === 'dotenv-generated')
logger.info(`[security] Generated ${PROXY_SECRET_ENV_KEY} in .env for signed proxy URLs.`)
else if (proxySecretResolved?.source === 'memory-generated')
logger.warn(`[security] Generated an in-memory ${PROXY_SECRET_ENV_KEY} (could not write .env). Signed URLs will break across restarts.`)
// Setup runtimeConfig for proxies and devtools.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

…rning

Two fixes from code review:

1. resolveProxySecret now runs AFTER the handler registration loop, gated
   on anyHandlerRequiresSigning. Users with only client-side scripts no
   longer get a surprise NUXT_SCRIPTS_PROXY_SECRET in their .env.

2. Missing secret in production is now a warning, not a fatal throw. This
   avoids breaking nuxt prepare / nuxt build / CI where the env var isn't
   set. withSigning already passes through gracefully when no secret is
   configured, so endpoints remain functional but unsigned. Users opt in
   to enforcement by setting the env var.
Avoids pulling nitro virtual modules (#imports) into unit tests that
import handler files for their exported utilities (e.g. instagram-embed
exports rewriteUrl/scopeCss alongside its default handler). The dynamic
import only runs at request time inside the nitro runtime where #imports
is available.
- resolveProxySecret: config precedence, env var fallback, .env auto-gen,
  existing key detection, newline handling, read-only FS fallback, process.env
  population (10 tests)
- verifyProxyRequest: URL signature mode, page token mode, expired tokens,
  tampered sigs, empty secret, any-params page token, dual-mode precedence
  (8 tests)

Total: 54 tests across sign.test.ts and resolve-proxy-secret.test.ts
Documents HMAC URL signing, page tokens, the generate-secret CLI,
dev auto-generation, and production setup requirements.
…ubleshooting

Lists which endpoints use signing, documents autoGenerateSecret option,
and covers common issues: 403 after deploy, cross-replica mismatches,
unexpected .env entries, and page token expiry.
…y endpoints

Adds a brief callout linking to the security guide on Google Maps,
Gravatar, Bluesky, Instagram, and X embed doc pages.
Signing requires a server runtime to verify HMACs. The module now
detects ssr: false and static presets (github-pages, etc.) and skips
secret resolution entirely, logging a clear warning instead.

Also documents the SSG/SPA limitations in the security guide.
@harlan-zw harlan-zw merged commit 3ca078c into main Apr 11, 2026
16 of 17 checks passed
@harlan-zw harlan-zw deleted the feat/proxy-url-signing branch April 11, 2026 03:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants