Skip to content

refactor(security): remove proxy URL signing and the page token#788

Open
harlan-zw wants to merge 1 commit into
mainfrom
refactor/proxy-token-removal
Open

refactor(security): remove proxy URL signing and the page token#788
harlan-zw wants to merge 1 commit into
mainfrom
refactor/proxy-token-removal

Conversation

@harlan-zw
Copy link
Copy Markdown
Collaborator

🔗 Linked issue

Resolves #783

❓ Type of change

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

📚 Description

The proxy page token was embedded per-request in the SSR payload. That made the payload non-deterministic, which breaks response etag hashing (the original report, #783). It also broke under response caching (a cached page serves a frozen, eventually-expired token → silent 403s), under SSG, and under client-side navigation — and it was weak protection anyway, since the token sits in plaintext in the payload and is trivially scrapeable.

Investigating a proper fix surfaced a deeper problem: the one endpoint where signing protects real cost — the Google Maps proxy, which injects your API key — has a client-component-emitted URL. It cannot be HMAC-signed without either shipping node:crypto to the browser (the build literally fails) or exposing an open signing oracle (which provides zero protection). Signing only ever worked for server-emitted URLs.

So this removes the proxy URL signing subsystem entirely:

  • Deletes the page-token plugin/composable, sign.ts, withSigning, sign-constants, proxy-secret resolution + .env auto-generation, the security config option, and the generate-secret CLI command.
  • Proxy and embed endpoints rely on their existing upstream-domain allowlist (they cannot be used as an open proxy). The Google Maps proxy keeps the API key server-side and is cache-shielded (static maps 7d, geocode 30d).
  • useScriptProxyUrl / buildProxyUrl now emit plain URLs. The SSR payload is deterministic.

⚠️ Breaking Changes

  • The scripts.security config option (secret, autoGenerateSecret) is removed.
  • NUXT_SCRIPTS_PROXY_SECRET and the npx @nuxt/scripts generate-secret command are removed.
  • Proxy endpoints no longer return 403 for unsigned requests. If quota abuse of the Google Maps proxy is a concern, add rate limiting via Nitro routeRules for /_scripts/proxy/** (documented in the first-party guide).

Verification

@nuxt/scripts builds; the static-map fixture builds with no node:crypto in the client bundle; 487 unit tests pass; test/fixtures/issue-783 confirms the static map renders a plain proxy URL and the SSR payload is byte-identical across requests.

The proxy page token was embedded per-request in the SSR payload, which made
the payload non-deterministic (breaking response etag hashing, issue #783),
broke under response caching and SSG, and was weak protection anyway since it
is scrapeable from the payload.

Signing also could not protect the one endpoint that mattered: the Google Maps
proxy URL is emitted by a client component, so it cannot be signed without
either shipping node:crypto to the browser or exposing a signing oracle.

Remove the proxy URL signing subsystem entirely:

- Delete the page-token plugin/composable, sign.ts, withSigning, sign-constants,
  and the proxy secret resolution + .env auto-generation.
- Drop the `security` config option and the generate-secret CLI command.
- Proxy/embed endpoints rely on their existing upstream-domain allowlist; the
  Google Maps proxy keeps its API key server-side and is cache-shielded.
- useScriptProxyUrl / buildProxyUrl emit plain URLs; the SSR payload is now
  deterministic.

Resolves #783
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 21, 2026

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

Project Deployment Actions Updated (UTC)
scripts-playground Error Error May 21, 2026 4:28am

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 21, 2026

Open in StackBlitz

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

commit: c8fe15b

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR removes the HMAC URL signing and per-request proxy token mechanisms from Nuxt Scripts, replacing them with a simpler allowlist-based endpoint security model. Changes include deleting signing utilities and CLI commands, removing withSigning wrappers from all proxy endpoints, simplifying URL construction by eliminating the secret parameter, and removing the useScriptProxyToken composable and proxy-token.server plugin. Documentation is updated to reflect the new allowlist-based approach. Tests for signing behavior are removed or simplified, and a new e2e test validates that proxy URLs are now unsigned and SSR payloads remain deterministic across requests, addressing issue #783.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'refactor(security): remove proxy URL signing and the page token' is concise, specific, and accurately summarizes the main change of removing proxy signing/page token subsystem.
Description check ✅ Passed The description provides clear context: explains the problem (non-deterministic SSR payload breaking ETags), the root cause (signing can't protect client-emitted URLs), the solution (removing signing subsystem), and documents breaking changes. It is directly related to the changeset.
Linked Issues check ✅ Passed The PR comprehensively resolves issue #783: it removes the proxy token from the SSR payload (making it deterministic for ETag hashing), eliminates dev auto-generation, allows unsecured operation, and maintains upstream-domain allowlisting for security.
Out of Scope Changes check ✅ Passed All changes are tightly scoped to removing the signing subsystem and page token infrastructure: documentation updates, deletion of sign utilities, removal of withSigning wrappers, CLI command removal, config option removal, and test updates. No unrelated changes detected.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/proxy-token-removal

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: 2

Caution

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

⚠️ Outside diff range comments (2)
test/unit/embed-rewriters.test.ts (1)

93-100: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make the Bluesky avatar rewrite assertion strict for unsigned-URL regressions.

This currently uses toContain, so a URL with legacy params could still pass. Please assert the exact unsigned URL (or explicitly assert absence of _pt, _ts, sig).

Proposed test tightening
   it('rewrites the author avatar', () => {
     const post = {
       author: { avatar: 'https://cdn.bsky.app/img/avatar.jpg' },
     }
     rewriteBlueskyPostImages(post, BSKY_PATH)
-    expect(post.author.avatar).toContain(encodeURIComponent('https://cdn.bsky.app/img/avatar.jpg'))
-    expect(post.author.avatar).toContain(BSKY_PATH)
+    expect(post.author.avatar).toBe(
+      `${BSKY_PATH}?url=${encodeURIComponent('https://cdn.bsky.app/img/avatar.jpg')}`,
+    )
   })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unit/embed-rewriters.test.ts` around lines 93 - 100, The test for avatar
rewriting is too loose—update the assertion in the test that calls
rewriteBlueskyPostImages (using BSKY_PATH) so it verifies the exact unsigned URL
returned for post.author.avatar (not using toContain), or alternatively assert
that the avatar string does not include legacy signing query params `_pt`,
`_ts`, or `sig`; reference post.author.avatar and the rewriteBlueskyPostImages
helper to locate where to change the expectation and ensure the result matches
the precise unsigned format.
packages/script/src/runtime/server/google-maps-geocode-proxy.ts (1)

15-48: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Ignore the retired signing params here for rollout compatibility.

After removing withSigning, stale pages/client bundles can still send _pt, _ts, and sig. Letting those flow into safeQuery creates distinct cache keys for the same geocode lookup and can cause avoidable paid misses right after deploy.

💡 Suggested patch
-  const { key: _clientKey, ...safeQuery } = query
+  const {
+    key: _clientKey,
+    _pt: _legacyPageToken,
+    _ts: _legacyTimestamp,
+    sig: _legacySignature,
+    ...safeQuery
+  } = query
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/script/src/runtime/server/google-maps-geocode-proxy.ts` around lines
15 - 48, The proxy is inadvertently including retired signing params (_pt, _ts,
sig) in safeQuery which creates distinct cache keys and causes paid misses;
update the query extraction around getQuery/event so that, in addition to
removing key (currently destructured as key: _clientKey), you also remove _pt,
_ts, and sig (e.g., destructure { key: _clientKey, _pt, _ts, sig, ...safeQuery }
= query) before calling withQuery and cachedGeocodeFetch (references: getQuery,
safeQuery, withQuery, cachedGeocodeFetch) so legacy params are stripped from the
geocodeUrl/cache key.
🧹 Nitpick comments (1)
packages/script/src/registry.ts (1)

685-688: Document or apply throttling for the unsigned Google Maps routes.

These two handlers now proxy billable Google APIs with a server-side key and no request-level gate. If this move to allowlist-only is intentional, please make sure the release notes/docs point users at Nitro routeRules throttling for these paths; otherwise they can be hotlinked to drain quota.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/script/src/registry.ts` around lines 685 - 688, The new
serverHandlers exposing '/_scripts/proxy/google-static-maps' and
'/_scripts/proxy/google-maps-geocode' are unthrottled billable endpoints; add
request-level throttling or document required Nitro routeRules for these routes.
Either implement a server-side rate limiter middleware in the handlers
referenced ('./runtime/server/google-static-maps-proxy' and
'./runtime/server/google-maps-geocode-proxy') or add Nitro routeRules for the
two routes with strict rate/limit settings and include a comment linking to the
docs; ensure the chosen approach rejects or 429s requests when limits are
exceeded and reference the serverHandlers entries so reviewers can verify the
change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/script/src/runtime/server/google-static-maps-proxy.ts`:
- Around line 11-13: The cache-key generation currently only strips the 'key'
param via the STRIP_PARAMS set; update STRIP_PARAMS to also include the legacy
signing parameters '_pt', '_ts', and 'sig' so those are removed from the proxied
URL before creating the cache key (look for the STRIP_PARAMS constant in
google-static-maps-proxy.ts and add the three legacy names to it).

In `@test/unit/proxy-url.test.ts`:
- Around line 52-55: The test for buildProxyUrl should avoid using the same
object instance twice; change the spec so it calls buildProxyUrl with two
separate but equivalent query objects (e.g., const query1 = { a: '1', b:
['x','y'] } and const query2 = { a: '1', b: ['x','y'] }) and assert the returned
URLs are equal, and optionally assert query1 still equals its original shape
after the calls to guard against in-place mutation by buildProxyUrl; locate and
update the test around the existing it('is deterministic: same inputs produce
identical URLs') that currently reuses the same query variable.

---

Outside diff comments:
In `@packages/script/src/runtime/server/google-maps-geocode-proxy.ts`:
- Around line 15-48: The proxy is inadvertently including retired signing params
(_pt, _ts, sig) in safeQuery which creates distinct cache keys and causes paid
misses; update the query extraction around getQuery/event so that, in addition
to removing key (currently destructured as key: _clientKey), you also remove
_pt, _ts, and sig (e.g., destructure { key: _clientKey, _pt, _ts, sig,
...safeQuery } = query) before calling withQuery and cachedGeocodeFetch
(references: getQuery, safeQuery, withQuery, cachedGeocodeFetch) so legacy
params are stripped from the geocodeUrl/cache key.

In `@test/unit/embed-rewriters.test.ts`:
- Around line 93-100: The test for avatar rewriting is too loose—update the
assertion in the test that calls rewriteBlueskyPostImages (using BSKY_PATH) so
it verifies the exact unsigned URL returned for post.author.avatar (not using
toContain), or alternatively assert that the avatar string does not include
legacy signing query params `_pt`, `_ts`, or `sig`; reference post.author.avatar
and the rewriteBlueskyPostImages helper to locate where to change the
expectation and ensure the result matches the precise unsigned format.

---

Nitpick comments:
In `@packages/script/src/registry.ts`:
- Around line 685-688: The new serverHandlers exposing
'/_scripts/proxy/google-static-maps' and '/_scripts/proxy/google-maps-geocode'
are unthrottled billable endpoints; add request-level throttling or document
required Nitro routeRules for these routes. Either implement a server-side rate
limiter middleware in the handlers referenced
('./runtime/server/google-static-maps-proxy' and
'./runtime/server/google-maps-geocode-proxy') or add Nitro routeRules for the
two routes with strict rate/limit settings and include a comment linking to the
docs; ensure the chosen approach rejects or 429s requests when limits are
exceeded and reference the serverHandlers entries so reviewers can verify the
change.
🪄 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: d9ed0ee7-976d-4cc4-b29a-4b20717d4b21

📥 Commits

Reviewing files that changed from the base of the PR and between f46ccb6 and c8fe15b.

📒 Files selected for processing (40)
  • docs/content/docs/1.guides/2.first-party.md
  • docs/content/scripts/bluesky-embed.md
  • docs/content/scripts/google-maps/2.api/1b.static-map.md
  • docs/content/scripts/google-maps/index.md
  • docs/content/scripts/gravatar.md
  • docs/content/scripts/instagram-embed.md
  • docs/content/scripts/x-embed.md
  • packages/script/src/cli.ts
  • packages/script/src/module.ts
  • packages/script/src/registry.ts
  • packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsStaticMap.vue
  • packages/script/src/runtime/composables/useScriptProxyToken.ts
  • packages/script/src/runtime/composables/useScriptProxyUrl.ts
  • packages/script/src/runtime/plugins/proxy-token.server.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/cached-upstream.ts
  • packages/script/src/runtime/server/utils/embed-rewriters.ts
  • packages/script/src/runtime/server/utils/image-proxy.ts
  • packages/script/src/runtime/server/utils/instagram-embed.ts
  • packages/script/src/runtime/server/utils/proxy-url.ts
  • packages/script/src/runtime/server/utils/sign-constants.ts
  • packages/script/src/runtime/server/utils/sign.ts
  • packages/script/src/runtime/server/utils/withSigning.ts
  • packages/script/src/runtime/server/x-embed.ts
  • packages/script/src/runtime/types.ts
  • test/e2e/issue-783-proxy-token-payload.test.ts
  • test/fixtures/issue-783/app.vue
  • test/fixtures/issue-783/nuxt.config.ts
  • test/fixtures/issue-783/package.json
  • test/fixtures/issue-783/pages/index.vue
  • test/unit/embed-rewriters.test.ts
  • test/unit/proxy-url.test.ts
  • test/unit/resolve-proxy-secret.test.ts
  • test/unit/sign.test.ts
  • test/unit/use-script-proxy-url.test.ts
  • test/unit/with-signing.test.ts
💤 Files with no reviewable changes (15)
  • test/unit/sign.test.ts
  • docs/content/scripts/bluesky-embed.md
  • test/unit/resolve-proxy-secret.test.ts
  • docs/content/scripts/google-maps/2.api/1b.static-map.md
  • packages/script/src/runtime/types.ts
  • packages/script/src/runtime/server/utils/withSigning.ts
  • packages/script/src/runtime/server/utils/sign-constants.ts
  • packages/script/src/runtime/composables/useScriptProxyToken.ts
  • docs/content/scripts/instagram-embed.md
  • packages/script/src/runtime/plugins/proxy-token.server.ts
  • docs/content/scripts/x-embed.md
  • docs/content/scripts/gravatar.md
  • test/unit/with-signing.test.ts
  • packages/script/src/runtime/server/utils/sign.ts
  • docs/content/scripts/google-maps/index.md

Comment on lines +11 to +13
// Strip the client-provided API key so the cache key is pinned to the actual
// map being requested (the real key is server-injected below).
const STRIP_PARAMS = new Set(['key'])
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep stripping the legacy signing params from the cache key.

Old HTML/client bundles can still hit this route with _pt, _ts, and sig during a rolling deploy. Leaving them on the proxied URL turns one logical map request into many cache entries and needlessly burns Static Maps quota.

💡 Suggested patch
-const STRIP_PARAMS = new Set(['key'])
+const STRIP_PARAMS = new Set(['key', '_pt', '_ts', 'sig'])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/script/src/runtime/server/google-static-maps-proxy.ts` around lines
11 - 13, The cache-key generation currently only strips the 'key' param via the
STRIP_PARAMS set; update STRIP_PARAMS to also include the legacy signing
parameters '_pt', '_ts', and 'sig' so those are removed from the proxied URL
before creating the cache key (look for the STRIP_PARAMS constant in
google-static-maps-proxy.ts and add the three legacy names to it).

Comment on lines 52 to 55
it('is deterministic: same inputs produce identical URLs', () => {
const query = { a: '1', b: ['x', 'y'] }
const a = buildProxyUrl('/api/proxy', query, SECRET)
const b = buildProxyUrl('/api/proxy', query, SECRET)
expect(a).toBe(b)
})

it('different secrets produce different signatures', () => {
const query = { k: 'v' }
const a = buildProxyUrl('/api/proxy', query, SECRET)
const b = buildProxyUrl('/api/proxy', query, 'different-secret-value')
expect(a).not.toBe(b)

const sigA = a.match(/sig=([a-f0-9]+)$/)?.[1]
const sigB = b.match(/sig=([a-f0-9]+)$/)?.[1]
expect(sigA).toBeTruthy()
expect(sigB).toBeTruthy()
expect(sigA).not.toBe(sigB)
expect(buildProxyUrl('/api/proxy', query)).toBe(buildProxyUrl('/api/proxy', query))
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Strengthen the determinism test to avoid same-reference blind spots.

Using the same object instance on both calls can miss mutation side effects. Use two equivalent objects (and optionally assert the original object is unchanged).

Proposed test update
   it('is deterministic: same inputs produce identical URLs', () => {
-    const query = { a: '1', b: ['x', 'y'] }
-    expect(buildProxyUrl('/api/proxy', query)).toBe(buildProxyUrl('/api/proxy', query))
+    const queryA = { a: '1', b: ['x', 'y'] }
+    const queryB = { a: '1', b: ['x', 'y'] }
+    expect(buildProxyUrl('/api/proxy', queryA)).toBe(buildProxyUrl('/api/proxy', queryB))
+    expect(queryA).toEqual({ a: '1', b: ['x', 'y'] })
   })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('is deterministic: same inputs produce identical URLs', () => {
const query = { a: '1', b: ['x', 'y'] }
const a = buildProxyUrl('/api/proxy', query, SECRET)
const b = buildProxyUrl('/api/proxy', query, SECRET)
expect(a).toBe(b)
})
it('different secrets produce different signatures', () => {
const query = { k: 'v' }
const a = buildProxyUrl('/api/proxy', query, SECRET)
const b = buildProxyUrl('/api/proxy', query, 'different-secret-value')
expect(a).not.toBe(b)
const sigA = a.match(/sig=([a-f0-9]+)$/)?.[1]
const sigB = b.match(/sig=([a-f0-9]+)$/)?.[1]
expect(sigA).toBeTruthy()
expect(sigB).toBeTruthy()
expect(sigA).not.toBe(sigB)
expect(buildProxyUrl('/api/proxy', query)).toBe(buildProxyUrl('/api/proxy', query))
})
it('is deterministic: same inputs produce identical URLs', () => {
const queryA = { a: '1', b: ['x', 'y'] }
const queryB = { a: '1', b: ['x', 'y'] }
expect(buildProxyUrl('/api/proxy', queryA)).toBe(buildProxyUrl('/api/proxy', queryB))
expect(queryA).toEqual({ a: '1', b: ['x', 'y'] })
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unit/proxy-url.test.ts` around lines 52 - 55, The test for buildProxyUrl
should avoid using the same object instance twice; change the spec so it calls
buildProxyUrl with two separate but equivalent query objects (e.g., const query1
= { a: '1', b: ['x','y'] } and const query2 = { a: '1', b: ['x','y'] }) and
assert the returned URLs are equal, and optionally assert query1 still equals
its original shape after the calls to guard against in-place mutation by
buildProxyUrl; locate and update the test around the existing it('is
deterministic: same inputs produce identical URLs') that currently reuses the
same query variable.

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.

Nuxt Scripts injects proxy token into payload even if not used

1 participant