Skip to content

fix: centralize protocol-relative URL guard to handle percent-encoded delimiters#888

Merged
southpolesteve merged 1 commit intomainfrom
fix/normalize-encoded-delimiters-in-protocol-guard
Apr 24, 2026
Merged

fix: centralize protocol-relative URL guard to handle percent-encoded delimiters#888
southpolesteve merged 1 commit intomainfrom
fix/normalize-encoded-delimiters-in-protocol-guard

Conversation

@southpolesteve
Copy link
Copy Markdown
Collaborator

Summary

Six places in the request pipeline inlined a variation of:

pathname.replaceAll("\\", "/").startsWith("//")

to reject protocol-relative paths before they reach the trailing-slash redirect emitter (which would otherwise echo them back into a Location header). The check handles literal delimiters but not their percent-encoded forms — /%5Cevil.com/ survives the guard, then normalizePathnameForRouteMatchStrict re-encodes the decoded backslash back to %5C (preserving it as part of a single path segment), so the 308 trailing-slash redirect ends up with Location: /%5Cevil.com. Browsers percent-decode Location headers and WHATWG URL treats \ as /, so the browser resolves that to a protocol-relative host.

Changes

Factor the leading-segment shape check into a new isOpenRedirectShaped helper in server/request-pipeline.ts that recognises literal (//, /\) and percent-encoded (%5C, %2F) variants, and swap each call site over to it. Also add a defense-in-depth check in normalizeTrailingSlash so a future caller that skips the upstream guard still can't emit a bad Location.

Updated call sites:

  • server/request-pipeline.tsguardProtocolRelativeUrl + normalizeTrailingSlash
  • server/prod-server.ts — App Router and Pages Router Node handlers
  • server/app-router-entry.ts — default Cloudflare Worker entry
  • server/app-ssr-entry.ts — SSR environment entry
  • index.ts — Pages Router Vite dev middleware
  • deploy.ts — generated Pages Router Cloudflare Worker (inlined copy kept in sync)

Tests

Regression tests in tests/request-pipeline.test.ts cover the encoded variants plus the defense-in-depth path. Updates the pinned string assertion in tests/deploy.test.ts that was checking for the old inlined pattern.

… delimiters

Six places in the request pipeline inlined a variation of:

  pathname.replaceAll("\\", "/").startsWith("//")

to reject protocol-relative paths before they reach the trailing-slash
redirect emitter (which would otherwise echo them into a Location header).
The check handles literal delimiters but not their percent-encoded forms —
`/%5Cevil.com/` survives the guard, then `normalizePathnameForRouteMatchStrict`
re-encodes the decoded backslash back to `%5C` (preserving it as part of a
single path segment), so the 308 trailing-slash redirect ends up with
`Location: /%5Cevil.com`. Browsers percent-decode Location headers and
WHATWG URL treats backslash as forward slash, so the browser resolves that
to a protocol-relative host.

Factor the leading-segment shape check into a new `isOpenRedirectShaped`
helper in `server/request-pipeline.ts` that recognises literal (`//`, `/\\`)
and percent-encoded (`%5C`, `%2F`) variants, and swap each call site over
to it. Also add a defense-in-depth check in `normalizeTrailingSlash` so a
future caller that skips the upstream guard still can't emit a bad Location.

Updated call sites:
  - server/request-pipeline.ts — guardProtocolRelativeUrl + normalizeTrailingSlash
  - server/prod-server.ts — App Router and Pages Router Node handlers
  - server/app-router-entry.ts — default Cloudflare Worker entry
  - server/app-ssr-entry.ts — SSR environment entry
  - index.ts — Pages Router Vite dev middleware
  - deploy.ts — generated Pages Router Cloudflare Worker (inlined, kept in sync)

Regression tests in tests/request-pipeline.test.ts cover the encoded
variants plus the defense-in-depth path. Updates the pinned string
assertion in tests/deploy.test.ts that was checking for the old inlined
pattern.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 24, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@888

commit: 4c8d73d

@github-actions
Copy link
Copy Markdown

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original

@southpolesteve
Copy link
Copy Markdown
Collaborator Author

/bigbonk review please. Also can you be sure this fix is comprehensive. Did we get it everywhere?

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Review

The fix is correct, well-tested, and appropriately defensive. Approving.

Root cause check ✓

Walked through the attack on main (commit before this PR):

  1. GET /%5Cevil.com/ arrives
  2. Existing guard: pathname.replaceAll("\\", "/").startsWith("//") — literal backslash replacement does not touch %5C, so /%5Cevil.com/ still starts with /%, guard passes
  3. normalizePathnameForRouteMatchStrict decodes %5C to \ segment-wise, then re-encodes unsafe bytes inside a segment, leaving /%5Cevil.com/ as a single segment
  4. trailingSlash: false + path ends with / → 308 with Location: /%5Cevil.com
  5. Browser percent-decodes the Location header; WHATWG URL treats \ as /; resolves to //evil.comhttp://evil.com/

isOpenRedirectShaped correctly catches this at step 2 by examining the leading bytes before decode, and the defense-in-depth check in normalizeTrailingSlash catches it at step 4 even if an upstream caller is skipped.

Correctness of isOpenRedirectShaped

  • Case sensitivity: .toLowerCase() on afterSlash.slice(0, 3) handles both %5C/%5c and %2F/%2f. RFC 3986 §2.1 confirms percent-encoding is case-insensitive.
  • Length guard: afterSlash.length >= 3 means a truncated /% or /%Z correctly falls through so the downstream decode path can return 400 rather than 404. Test returns null for malformed percent-encoding locks this in.
  • No false positives: UTF-8 encoded paths like /%E4%B8%AD have afterSlash.slice(0, 3) === "%e4", which matches neither %5c nor %2f. Locked in by returns null when % appears after the leading slash but not as a delimiter.
  • Leading-segment only: /foo/%5Cbar correctly passes because the check only examines position 0 after the leading /. Legitimate (if unusual) encoded paths elsewhere still resolve normally.

Call-site coverage ✓

All six inlined copies migrated, plus deploy.ts gets an inlined mirror with a "keep in sync" comment (necessary because the generated worker can't import from local source at build time). Spot-checked rg 'startsWith\("//"\)' for stragglers — remaining hits are in unrelated contexts (image URL validation, client-side router redirect guards, full-URL detection for http:///https://), none of which emit Location headers for path-shaped open redirects. The image-optimization validator has its own independent resolved.origin !== base defense-in-depth check that already blocks the encoded-backslash variant.

Also verified the reorder in prod-server.ts and app-router-entry.ts: the guard now runs before normalizePathnameForRouteMatchStrict / decodeURIComponent, which is the key correctness property. Previously the literal-only guard could run after decode since decode doesn't introduce //, but the encoded-check must run pre-decode.

Test coverage ✓

tests/request-pipeline.test.ts covers:

  • Literal //, /\
  • Encoded %5C, %5c, %2F, %2f
  • Double-encoded %5C%5C
  • Negative cases: UTF-8 chars, single encoded ASCII (/%61dmin), encoded delimiters after the first segment, malformed percent-encoding
  • Defense-in-depth via normalizeTrailingSlash directly, asserting both the 404 status and absent Location header for both trailingSlash: true and false

tests/deploy.test.ts verifies the generated worker actually inlines the new helper with the %5c/%2f constants and calls it against pathname.

Local verification

  • vp test run tests/request-pipeline.test.ts tests/deploy.test.ts tests/shims.test.ts → 1100/1100 pass
  • vp lint on all 6 modified source files → clean
  • vp check — only pre-existing unrelated error (benchmarks/vinext/vite.config.ts workspace link), no issues introduced by this PR

Minor observation (non-blocking)

deploy.ts keeps an inlined copy of isOpenRedirectShaped. The comment explicitly calls out the "keep in sync" requirement and the deploy test now asserts the inlined implementation contains %5c/%2f, which is a reasonable guard against drift. If the two copies diverge in the future, extracting a shared template string or a codegen helper would remove this footgun — but that's a refactor for another PR, not a blocker here.

LGTM.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 24, 2026

Posted an approving review on PR #888. Summary of the analysis:

Verdict: Approved.

The fix is correct. Key findings:

  1. Root cause confirmed — traced the attack step-by-step on main: literal-only guard → segment-wise decode re-encodes %5C → trailing-slash emitter produces Location: /%5Cevil.com → browser percent-decodes to /\evil.com → WHATWG treats \ as / → navigates to evil.com.

  2. isOpenRedirectShaped is correct — case-insensitive per RFC 3986 §2.1; length-guarded so malformed percent falls through to the 400 path; leading-segment-only so legitimate encoded chars elsewhere still work.

  3. All call sites migrated — all 6 places centralized; deploy.ts gets an inlined mirror (necessary, commented as "keep in sync"); the reorder in prod-server.ts/app-router-entry.ts to run the guard before decode is the key correctness property.

  4. Defense-in-depthnormalizeTrailingSlash itself refuses to emit a Location for open-redirect-shaped paths.

  5. Tests pass locally — 1100/1100 in the relevant files; lint clean; only pre-existing unrelated type error in benchmarks/.

Minor non-blocking note: the inlined copy in deploy.ts is a potential drift risk, but the new deploy test asserting %5c/%2f presence helps guard against it.

github run

@southpolesteve southpolesteve merged commit c58daa6 into main Apr 24, 2026
32 checks passed
@southpolesteve southpolesteve deleted the fix/normalize-encoded-delimiters-in-protocol-guard branch April 24, 2026 20:33
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.

1 participant