fix(routing): re-encode trailing-slash Location for non-Latin-1 paths - #2011
Conversation
9cf6226 to
60dd733
Compare
|
/bigbonk review for issues |
commit: |
There was a problem hiding this comment.
Review: trailing-slash Location re-encoding
Solid, well-scoped fix. I verified the root-cause analysis against the actual pipeline and it holds up:
normalizePathnameForRouteMatchStrict(routing/utils.ts:149) decodes each segment viadecodeURIComponentand re-encodes only the path delimiters# ? / \(encodePathDelimiters,routing/utils.ts:105-109). SopathnamereachingnormalizeTrailingSlashis segment-decoded with%23/%2f/%3f/%5cleft intact — exactly as the PR describes.- Keeping
%plus hex digits in the allowlist correctly preserves those%xxdelimiter escapes (no%23→%2523double-encoding), whileencodeURIwould have broken them. Good call. - The
uflag matching astral code points whole is the right way to avoid lone-surrogate output; the emoji test confirms/%F0%9F%98%80/. searchis taken fromurl.searchof aURLobject at both real callers (app-rsc-handler.ts:477,pages-request-pipeline.ts:232), which is already percent-encoded, so not re-encoding it is correct.
Parity is preserved. normalizeTrailingSlash is the single shared helper used by all three request paths — app-rsc-handler.ts:473, pages-request-pipeline.ts:232, and dev index.ts:3451 — so the fix lands uniformly with no dev/prod divergence.
Verification I ran locally:
vp test run tests/request-pipeline.test.ts -t normalizeTrailingSlash→ 25 passed (incl. 5 new #1979 cases)vp check packages/vinext/src/server/request-pipeline.ts tests/request-pipeline.test.ts→ format/lint/types clean
The security framing (control chars CR/LF/NUL falling outside the allowlist and being encoded) is a nice hardening bonus, and isOpenRedirectShaped still runs on the decoded pathname before encoding, so the open-redirect guard is unaffected.
One non-blocking process note below re: the hand-authored changeset.
| @@ -0,0 +1,5 @@ | |||
| --- | |||
There was a problem hiding this comment.
Non-blocking process note: AGENTS.md says changesets should not be authored by hand — they're generated in CI from the Conventional Commit subject (fix(routing): ...), and the commit here already qualifies. The repo's .changeset/README.md does state that hand-committed changesets are still honored, so this won't break the release; it's just redundant with the auto-generated one. If you'd prefer to follow the documented convention you could drop this file and rely on the commit message. Either way the content is accurate. Up to a maintainer's preference.
There was a problem hiding this comment.
Good catch — dropped the hand-authored changeset. The fix(routing): commit subject already drives the auto-generated patch changeset (per AGENTS.md "never create changesets manually"), so the committed file was redundant. Removed in ab208c12.
| // split. The query string comes verbatim from the request URL and is already | ||
| // encoded, so it must not be re-encoded here. Refs cloudflare/vinext#1979 | ||
| const encodedPathname = normalizedPathname.replace( | ||
| /[^A-Za-z0-9\-._~!$&'()*+,;=:@/%]/gu, |
There was a problem hiding this comment.
Allowlist matches RFC 3986 pchar + / + %, which is correct. Worth being aware that + is intentionally kept raw here — that's consistent with how Next.js treats + in path segments (it's a valid sub-delim and only carries space semantics in query strings, which you correctly leave untouched). No change needed; just noting the deliberate choice for future readers, since the comment block enumerates everything else but not why +/,/; stay raw.
There was a problem hiding this comment.
Added the rationale to the comment block: sub-delimiters (!$&'()*+,;=) and :@ are valid pchar and kept raw to match Next.js, and + only carries space semantics in the query string (which stays untouched). Thanks for flagging — clearer for future readers now. ab208c12.
|
Review posted successfully. Here's my summary: Review complete — PR #2011Verdict: Approve-quality fix. The change is correct, well-reasoned, and well-tested. What I verified
Comments posted (both non-blocking)
No correctness, security, or parity issues found. |
normalizeTrailingSlash built the redirect Location from the already percent-decoded pathname. A character above U+00FF (e.g. a CJK slug or emoji under trailingSlash:true) made the Headers constructor throw 'Cannot convert argument to a ByteString', caught by the outer handler and surfaced as a 500 instead of a 308. Latin-1 characters such as spaces did not throw but produced a malformed, un-percent-encoded Location. Percent-encode the redirect target before building the response, encoding every character that is invalid raw in an RFC 3986 path while preserving existing %xx escapes and / delimiters so already-encoded delimiters like %23 are not double-encoded. Fixes cloudflare#1979 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
60dd733 to
ab208c1
Compare
|
/bigbonk re-review for correctness and nextjs parity issues |
|
@james-elicx Bonk workflow was cancelled. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk re-review for nextjs parity issues |
There was a problem hiding this comment.
Re-review: correctness + Next.js parity
Re-reviewed the latest commit (ab208c1) focused on correctness and parity. No blocking issues — the fix is sound.
Correctness verified
- The
%-in-allowlist + lone-%edge case is unreachable. All three callers —app-rsc-handler.ts:473,pages-request-pipeline.ts:232, and devindex.ts:3451— runnormalizePathnameForRouteMatchStrictfirst, which throws on malformed percent-encoding and returns 400 beforenormalizeTrailingSlashever sees the path (index.ts:3417-3424). So a malformed escape like/100%or/a%zzb(which the allowlist would otherwise pass through raw) can't reach the encoder in practice. Keeping%in the safe set is therefore both necessary (to avoid%23→%2523) and safe. - Open-redirect guard is unaffected.
isOpenRedirectShaped(pathname)runs on the decoded path before encoding (request-pipeline.ts:326). Encoding only hardens:/stays in the allowlist (delimiter preserved) and backslash is encoded to%5C, so the encode step can never reintroduce a//- or/\-shaped Location. Confirmed:/\\evil.com→/%5C%5Cevil.com- control chars
/\tx→/%09x(CR/LF/NUL hardening as described)
Parity verified
- Wire output matches Next.js. In Next.js the request path is normally already percent-encoded (
/%E6%97%A5%E6%9C%AC,/about%20us). vinext's pipeline decodes segment-wise then this fix re-encodes, producing the same on-the-wireLocation. So a raw space round-trips back to%20, and CJK/emoji round-trip to their UTF-8%xxform — identical to what Next.js emits. - Single shared helper, no dev/prod divergence.
normalizeTrailingSlashis the one helper used by all three request paths, so the fix lands uniformly. searchcorrectly not re-encoded — both real callers passurl.searchfrom aURLobject (already encoded).
Tests
vp test run tests/request-pipeline.test.ts -t normalizeTrailingSlash → 25 passed (incl. the 5 new #1979 cases). Coverage is good: CJK, emoji surrogate pairs, spaces, already-encoded delimiters, and reserved ASCII.
The author already addressed the prior review (dropped the hand-authored changeset, expanded the rationale comment). LGTM.
|
Re-review posted. Summary of my findings: PR #2011 — Verdict: No blocking issues. The fix is correct and preserves Next.js parity. Key things I verified on this re-review (correctness + parity focus):
The author already addressed the earlier review (removed the hand-authored changeset, expanded the rationale comment). |
Summary
Locationheader, so paths containing characters above U+00FF (CJK slugs, emoji) no longer crash theHeadersconstructor.%xxescapes and/delimiters intact, so already-encoded delimiters such as%23are not double-encoded (a naiveencodeURIwould turn%23into%2523).tests/request-pipeline.test.tsfor non-Latin-1 slugs, emoji (astral/surrogate pairs), spaces, already-encoded delimiters, and reserved ASCII.Root Cause
normalizeTrailingSlashbuilt the redirectLocationasbasePath + normalizedPathname + search, wherenormalizedPathnamearrives already percent-decoded (seeapp-rsc-request-normalization→normalizePathnameForRouteMatchStrict, whichdecodeURIComponents each segment and only re-encodes the path delimiters# ? / \).For any path with a character above U+00FF — e.g.
/日本or an emoji undertrailingSlash: true—new Response(null, { headers: { Location: "/日本/" } })throwsTypeError: Cannot convert argument to a ByteStringin the Workers/undiciHeadersimplementation (which only accepts ByteStrings, code points ≤ 0xFF). The throw is caught by the outer handler and surfaces as a 500 Internal Server Error instead of the expected 308, making the page unreachable. Latin-1 characters such as spaces did not throw but emitted a malformed, un-percent-encodedLocationlike/about us/.The fix re-encodes the redirect target.
encodeURIis the wrong tool here because it escapes%, double-encoding the delimiter sequences the upstream normalizer deliberately left in place. Instead we encode against an RFC 3986 path allowlist, preserving%and/:The
uflag makes the regex match astral code points whole, so emoji surrogate pairs are not split into lone surrogates. Control characters (CR/LF/NUL) fall outside the allowlist and are encoded as well, hardening the redirect against header injection. The query string is taken verbatim from the request URL (already encoded) and is intentionally not re-encoded.References
pchargrammar): https://www.rfc-editor.org/rfc/rfc3986#section-3.3Verification
pnpm test tests/request-pipeline.test.ts— 102 passed (5 new regression cases for Trailing slash: redirect Location built from decoded pathname — 500 on non-Latin-1 paths (ByteString throw) #1979)npx vp check packages/vinext/src/server/request-pipeline.ts tests/request-pipeline.test.ts— format, lint, types clean