Response.redirect: parse and serialize the url into the Location header - #33126
Conversation
Response.redirect(url) placed the raw argument string into Location: spaces were not percent-encoded, non-ASCII came back as a Latin-1 read of the UTF-8 bytes, no normalization ran, and an embedded tab or newline threw "Header '51' has invalid value" instead of being stripped by the URL parser. Run the argument through the WHATWG URL parser and put the serialized href into Location. A non-absolute input keeps the raw string, since relative redirect targets are documented Bun behavior. FetchHeaders::put now takes a ZigString so that fallback passes the JS string in its native encoding, and the HTTPHeaderName header-validation error names the header instead of its enum index.
|
Updated 12:51 AM PT - Jun 30th, 2026
❌ @robobun, your commit 14ea8ce has 2 failures in
🧪 To try this PR locally: bunx bun-pr 33126That installs a local version of the PR into your bun-33126 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
This PR does not fix #24002, so I am not adding That issue asks
Concretely, |
WalkthroughFetchHeaders header-value APIs now take ChangesFetchHeaders ZigString encoding and redirect normalization
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 `@src/runtime/webcore/BakeResponse.rs`:
- Around line 203-207: The `headers.put` call in `BakeResponse` is re-wrapping
the path through `path_utf8.slice()` and `ZigString::init(...)`, which drops the
original JS encoding metadata. Update the `Location` header write to pass the
original `ZigString` source for the path, using the same
`path_arg`/`global_this` retrieval path already available in this scope (for
example via `path_arg.get_zig_string(global_this)?`), and avoid the UTF-8
round-trip so non-ASCII values stay intact.
🪄 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: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 75a7fafd-9c2b-44d3-9ba9-1972a956d41c
📒 Files selected for processing (8)
src/jsc/FetchHeaders.rssrc/jsc/bindings/webcore/FetchHeaders.cppsrc/runtime/webcore/BakeResponse.rssrc/runtime/webcore/Request.rssrc/runtime/webcore/Response.rstest/js/web/fetch/fetch.test.tstest/js/web/fetch/fetch_headers.test.jstest/js/web/fetch/response.test.ts
… encoding Response.render(path) had the same UTF-8-bytes-read-as-Latin-1 round trip that corrupted non-ASCII Response.redirect(url) values. Use the JS string's ZigString view directly, as the Response.redirect fallback now does, instead of converting to UTF-8 bytes first. The now-unused path_str/path_utf8 bindings are removed.
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/runtime/webcore/BakeResponse.rs:203-207— The PR description states "the otherputcallers all pass ASCII", butconstruct_renderdoes not:path_utf8.slice()is UTF-8 bytes from a user-supplied JS string, andZigString::inittags them Latin-1, soResponse.render("/café")still producesLocation: /café. This is the same bug class the PR fixes forResponse.redirect("/café")on a line the PR touched;init→from_bytes(or passpath_arg.get_zig_string(...)directly, as the redirect path now does) would close it. Pre-existing behavior, not a regression.Extended reasoning...
What
construct_renderinsrc/runtime/webcore/BakeResponse.rsputs the user-supplied path into theLocationheader via:let path_utf8 = path_str.to_utf8(); headers.put( HTTPHeaderName::Location, &ZigString::init(path_utf8.slice()), global_this, )?;
path_utf8.slice()is the UTF-8 encoding of an arbitrary JS string (Response.render(path)).ZigString::init(src/bun_core/string/mod.rs:1437) stores the pointer with no encoding tag, which means Latin-1; onlyinit_utf8/from_bytesset the UTF-8 tag. On the C++ side,WebCore__FetchHeaders__put→Zig::toStringCopy(helpers.h:187) checksisTaggedUTF8Ptr; when not tagged, the bytes go throughWTF::StringImpl::createas Latin-1 code points.Step-by-step
- JS calls
Response.render("/café")inside the Bake dev server. path_str.to_utf8()yields the UTF-8 bytes2F 63 61 66 C3 A9(é→0xC3 0xA9).ZigString::init(path_utf8.slice())wraps those bytes with no UTF-8 ptr tag → treated as Latin-1.headers.put(...)→Zig::toStringCopysees!isTaggedUTF8Ptr, callsStringImpl::createon the raw byte span, producing the WTF string"/café"(six Latin-1 code points).headers.get("location")→"/café"instead of"/café".
Why nothing prevents it
The PR explicitly changed
FetchHeaders::putfrom&[u8]to&ZigStringso callers can express the correct encoding, and the PR description justifies wrapping the remaining call sites inZigString::initwith "the otherputcallers all pass ASCII". That is true for the content-type / S3-URL / JSON-mime callers, but not forconstruct_render, whose argument is arbitrary user input. The newputdoc comment even says "a raw&[u8]parameter would force every caller into a Latin-1 read" —ZigString::initon UTF-8 bytes does exactly that.Impact
Low.
Response.renderis a Bake/dev-server-only API, and route paths are almost always ASCII, so this rarely bites in practice. It is also pre-existing: before this PR,puttook&[u8]and didZigString::initinternally, so behavior is byte-identical to before. But it is the same bug class the PR fixes forResponse.redirect("/café"), on a line the PR explicitly rewrote, and it contradicts the PR's stated rationale.Fix
One token:
ZigString::init(path_utf8.slice())→ZigString::from_bytes(path_utf8.slice())(auto-tags UTF-8 when non-ASCII). Alternatively, skip the UTF-8 round-trip entirely and pass the JS string's nativeZigString(path_arg.get_zig_string(global_this)?), exactly as the relative-redirect fallback now does. - JS calls
|
The BakeResponse finding is already addressed in 4d8b19e, which this review raced with: CodeRabbit raised the same thing against the first commit; its incremental re-review of 4d8b19e reported no remaining findings. The existing |
…tion
Response.redirect now serializes its url into Location, so
Response.redirect('https://cunny.school') yields 'https://cunny.school/',
matching Node and the fetch spec. elysia 1.4.28's 'map redirect' tests in
adapter/web-standard/map-response.test.ts and map-early-response.test.ts
assert the unserialized string and fail deterministically on every
platform. Skip them until the upstream assertion expects the trailing
slash.
…rgets
A relative redirect target containing a code point above U+00FF cannot be
a header value. Response.redirect now throws the same TypeError that
headers.set('location', s) and new Headers({ location: s }) already
throw for that input; it previously wrote a Latin-1-corrupted Location
without throwing. Test both sides of the boundary: '/café' (U+00E9)
round-trips, '/€' (U+20AC) and '/搜索' throw.
There was a problem hiding this comment.
Thanks for addressing both findings (vendor skips in 58a5acb, the U+00FF boundary test in aebb8f1) — I have nothing further, but since this is a user-facing Response.redirect behavior change with a deliberate compat tradeoff on the relative-URL fallback, I'd like a human to sign off.
Extended reasoning...
Overview
This PR makes Response.redirect(url) parse and serialize its argument through the WHATWG URL parser before placing it into the Location header (fetch spec steps 1 & 6), instead of passing the raw string. It also widens FetchHeaders::put from &[u8] to &ZigString so callers preserve the JS string's native encoding, updates ~7 call sites in Request.rs/Response.rs/BakeResponse.rs, fixes the canWriteHeader(HTTPHeaderName, ...) error message in FetchHeaders.cpp to print the header name instead of its enum index, adds tests, and skips two elysia vendor tests pinned to the old unserialized output.
Security risks
None identified. The change tightens header-value handling (URL serialization percent-encodes/strips control chars; the relative fallback now goes through the same isValidHTTPHeaderValue gate as Headers.set). No auth, crypto, or permission surfaces touched.
Level of scrutiny
Medium-high. This is a user-facing web API behavior change with two deliberate compat decisions a maintainer should ratify: (1) Bun continues to accept relative redirect targets (diverging from the spec's TypeError, preserving documented Bun behavior and #18414's test), and (2) relative targets containing code points > U+00FF now throw TypeError instead of silently writing mojibake — the author chose option (a) over percent-encoding the fallback, with a well-argued rationale, but it is a new throw on a path that previously returned a Response. The &[u8] → &ZigString signature change also crosses the Rust↔C++ FFI encoding boundary across several call sites.
Other factors
Both of my prior inline findings are resolved: 58a5acb added the elysia skipTests entries with verified globs, and aebb8f1 added the /café ↔ /€//搜索 boundary test plus an updated PR description. The bug-hunting system found nothing on this revision. CI on 58a5acb shows only two failures (test/package.json timeout on macOS aarch64, test-net-connect-memleak.js on Linux x64) that are unrelated to this change. Test coverage for the new behavior is thorough (serialization table, relative fallback, header-value boundary, error-message fix). The remaining reason not to auto-approve is purely that this is an intentional behavior change to a public API, not a mechanical fix.
|
All review findings are resolved (3 of 3 threads, plus CodeRabbit's incremental re-review reporting no actionable comments), and this is ready for a maintainer. Why
|
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Use BunString instead.
A WTFStringImpl-tagged BunString lets the C++ side ref the existing string (BunString::toWTFString) instead of copying its characters. Response.redirect now hands the parsed href, or the JS string's own WTF string on the relative fallback, straight to the header map, and Response.render does the same, which also removes their ZigString/UTF-8 round-trips. The ASCII byte-slice callers wrap in BunString::ascii, the exact ZigString the previous &[u8] signature built internally.
|
CI for 14ea8ce (the
I am not pushing a retrigger: the Alpine test fails on essentially every build until #33045 lands, so a re-roll would come back red on it. The darwin-26 artifact-download infra issue from my earlier comment has cleared (all darwin 26 jobs passed on this build). Once #33045 is in, this PR's CI should be fully green. |
Response.redirect(url)placed its argument into theLocationheader verbatim instead of parsing and serializing it, as https://fetch.spec.whatwg.org/#dom-response-redirect steps 1 and 6 require.Reproduction
Cause
Response::construct_redirect_implnever ran its argument through a URL parser. It converted the JS string to UTF-8 bytes and put those bytes intoLocationas Latin-1, soFetchHeaders::putwrapped its&[u8]argument in a Latin-1-taggedZigString.Separately,
canWriteHeader'sHTTPHeaderNameoverload inFetchHeaders.cppformatted the enum's numeric value into the error message (Locationis 51). That is reachable from any well-known header name, for examplenew Headers({ location: "a\nb" }).Fix
construct_redirect_implnow runs the argument throughbun_url::href_from_string(the same WHATWG parsernew Request(url)already uses) and puts the serialized href intoLocation.A non-absolute input keeps the raw string. The spec would throw a
TypeErrorthere, butResponse.redirect("/login")is documented Bun behavior (docs/runtime/http/server.mdx,docs/guides/http/server.mdx) and widely used, so it is preserved; this is called out in a code comment.FetchHeaders::puttakes a&BunStringinstead of&[u8]. AWTFStringImpl-tagged value carries its encoding and gets ref'd by the C++ side (BunString::toWTFString) instead of having its characters copied, exactly asHeaders.prototype.setdoes.Response.redirect("/café")previously producedLocation: /cafébecause that path went through a Latin-1 read of the JS string's UTF-8 bytes;Response.render(path)(the bake dev server) had the same round-trip and now hands the JS string straight toput, andconstruct_redirect_implhands it the parsed href (already aBunString) with no re-encode. The remaining byte-slice callers (the content-type sites, the presigned S3 url, the JSON mime) wrap inBunString::ascii, which is the sameZigString::initthatputbuilt internally before, so they are unchanged.One deliberate consequence: a relative target containing a code point above U+00FF, which cannot be a header value, now throws
TypeError: Header 'Location' has invalid value. That is exactly whatheaders.set("location", "/€"),new Headers({ location: "/€" }), andnew Response(null, { headers: { location: "/€" } })already do for the same input;Response.redirectwas the one path that instead silently wrote a Latin-1-corrupted value (Response.redirect("/€")producedLocation: /â¬). A test covers both sides of the boundary (/cafépasses,/€throws).canWriteHeader(HTTPHeaderName, ...)useshttpHeaderNameString(name)in its error message, so the example above now reportsHeader 'Location' has invalid value.Two existing tests asserted the unserialized
Locationvalue and were updated:http://example.comserializes ashttp://example.com/, matching Node and the spec.Two elysia vendor tests (
adapter/web-standard/map-response.test.tsandmap-early-response.test.ts) pin the unserializedLocationand are skipped intest/vendor.jsonwith a TEMPORARY note, following the existingws*connection.test.tsentry; they are the onlyResponse.redirectassertions in elysia 1.4.28's test tree.Not in this PR
The same statics also have a non-null
.bodywhere the spec saysnull. #33125 already fixes that, so this PR does not touch those lines.Verification
Both fail without the
src/change and pass with it. Also ran the headers, serve-static, serve-if-none-match, cookie, proxy, client-fetch, and bake response regression suites against the fixed build with no new failures.