Skip to content

Bun.Cookie: stop toString() from percent-encoding a parsed value a second time - #48384

Open
robobun wants to merge 3 commits into
mainfrom
robobun/a9729001/cookie-writer-wire-text
Open

robobun wants to merge 3 commits into
mainfrom
robobun/a9729001/cookie-writer-wire-text

Conversation

@robobun

@robobun robobun commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • toString() percent-encodes the value of a parsed cookie a second time. Bun.Cookie.parse("a=b%20c").toString() gives a=b%2520c; Path=/; SameSite=Lax.
  • Cookie::parse (src/jsc/bindings/Cookie.cpp:76) keeps the value text. Cookie::appendTo (Cookie.cpp:242) runs encodeURIComponent over every value.

Fix

  • Cookie::parse marks the value as wire text, and the value setter clears the mark. appendTo writes a marked value as it is when it is an RFC 6265 cookie-value with well-formed %XX escapes. It encodes every other value as before.
  • cookie.value does not change. Callers read it as wire text: the Introduce CookieMap.toCookieRequestHeader() to simplify handling of cookies across multiple fetch() requests #18806 helper sends ${name}=${value} in a Cookie header.
  • Verified: 22 new tests in test/js/bun/cookie/cookie.test.ts, cookie-map.test.ts and test/js/bun/http/bun-serve-cookies.test.ts. 11 fail on main.
  • Self-reviewed: 11 concerns raised, 11 addressed (notes).

Background

  • A cookie-value is printable ASCII without space, ", ,, ; and \. The encoder is the only check on the value, so other values still use it.
  • Considered decoding in Cookie.parse (Percent-decode values in Bun.Cookie.parse #32960). It changes cookie.value, and undici removed that decode as CVE-2026-9679.
  • This is a breaking change. Which release takes it? Migration: c.value = c.value keeps the 1.4.x bytes.

Downsides

  • Breaking, with no error: a server that sets a parsed cookie reads the decoded text on the next request (s:abc, CR LF for %0d%0a, U+FFFD for caf%E9). 1.4.x returned the .value it set.
  • The mark follows the Cookie object. A copy by value is still encoded twice: new Bun.Cookie(c.name, c.value), new Bun.Cookie(c).
  • Cost: Cookie::parse +16 instructions per call (500 to 516 for a=b), toString() of a cookie that was not parsed +2. .text grows 768 bytes, sizeof(Cookie) stays 72.
Notes

Reproduction (Bun 1.4.3, same code on main 2596ac5d39).

Bun.Cookie.parse("a=b%20c").toString();   // "a=b%2520c; Path=/; SameSite=Lax", and "a=b%25252520c" after three hops
const map = new Bun.CookieMap();
map.set(Bun.Cookie.parse("sid=s%3Aabc; Path=/; HttpOnly"));
map.toSetCookieHeaders();                 // ["sid=s%253Aabc; Path=/; HttpOnly; SameSite=Lax"]
Bun.Cookie.parse("tok=YWJj+/==").toString();   // "tok=YWJj%2B%2F%3D%3D; Path=/; SameSite=Lax"

In Bun.serve, req.cookies.set(Bun.Cookie.parse(upstream)) sends connect.sid=s%253Aabc.def%252Fghi for an upstream connect.sid=s%3Aabc.def%2Fghi.

The rule. toString(), serialize(), CookieMap#toSetCookieHeaders() and the Bun.serve Set-Cookie writer all end in Cookie::appendTo. It now has two cases.

  • The cookie came from Bun.Cookie.parse(string) or new Bun.Cookie(string), its value was not assigned since, and the value matches cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE ) (RFC 6265 section 4.1.1) with two hex digits after every %: the value is appended as it is.
  • Every other value goes through encodeURIComponent, as on main.
parsed pair Bun 1.4.3 writes this PR writes
a=b%20c a=b%2520c a=b%20c
connect.sid=s%3Aabc.def%2Fghi connect.sid=s%253Aabc.def%252Fghi connect.sid=s%3Aabc.def%2Fghi
u=%e8%af%bb u=%25e8%25af%25bb u=%e8%af%bb
tok=YWJj+/== tok=YWJj%2B%2F%3D%3D tok=YWJj+/==
q="abc" q=%22abc%22 q="abc"
a=b c a=b%20c a=b%20c
a=x,y a=x%2Cy a=x%2Cy
a=caf\xE9 a=caf%C3%A9 a=caf%C3%A9
a=100% a=100%25 a=100%25
a=b%20c d a=b%2520c%20d a=b%2520c%20d

The last row still gets one more layer. That happens for a value that has an escape and also a character that is not a cookie-octet, or a % that does not start an escape (a=b%20c%, a=x%%20y, a=a%20b%zz). The output is a cookie-value with well-formed escapes, so the next hop keeps it. For a parsed value, 18 characters are no longer escaped: # $ & + / : < = > ? @ [ ] ^ { | } and the backtick.

Why the guard. The name, the path and the domain are checked where a cookie is made. The value is not, because it was always escaped. Without the guard a parsed value with a control character, a comma, a quote, a backslash or a non-ASCII character would reach the header as it is.

The % clause goes away with #44664 or #46528. Bun.CookieMap reads a % that does not start an escape as U+FFFD. So a parsed value with such a % stays on the encode path, and Bun.CookieMap reads the text back. #44664 makes the reader keep that %, and #46528 carries the same change. This PR does not depend on either. When one of them lands, the test.failing in cookie.test.ts starts to pass and turns red. The change to make then:

  • remove the % check in isEncodedCookieValue (Cookie.cpp:252-253),
  • move strayPercentPairs into keptPairs in cookie.test.ts and delete the two tests below the copies test,
  • drop the % condition from cookies.mdx and from the serialize() JSDoc.

Why cookie.value stays. An earlier draft of this change decoded the value in Cookie::parse, as #32960 did. A review of that draft found that callers read cookie.value as wire text. I ran the helper from #18806 unchanged over HTTP on that draft: pref=x%3B%20admin%3Dtrue arrived as pref=x; admin=true, sid=s%3Aabc.def%2Fghi arrived as sid=s:abc.def/ghi, and a %0D%0A value made fetch throw. On 1.4.3 and on this PR all four pairs arrive unchanged. undici removed the same decode from its Set-Cookie parser in 6.27.0, 7.28.0 and 8.5.0 (GHSA-p88m-4jfj-68fv). RFC 6265 section 5.2 has no decoding step, and tough-cookie does not decode either.

Scope: the mark follows the Cookie object. Of 19 ways to hand a parsed value back to the writer, 6 now write a=b%20c: toString(), serialize() and a template literal on a cookie from Bun.Cookie.parse(string) or new Bun.Cookie(string), and map.set(cookie) for both. A cookie string also gets the mark back: Bun.Cookie.parse(cookie.toString()) and a cookie made from name=value text write like their source. The other 13 still write a=b%2520c, as 1.4.3 does, because they pass the value as text:

  • new Bun.Cookie(c.name, c.value), Bun.Cookie.from(c.name, c.value), map.set(c.name, c.value), c.value = c.value and a value read from a CookieMap.
  • new Bun.Cookie(c.toJSON()) and map.set(c.toJSON()).
  • new Bun.Cookie(c). The constructor reads a Cookie through CookieInit::fromJS, the way it reads any object. Cookie::parse stays the only producer of the mark. I did not add a copy rule to the constructor.

Tests pin both groups. The docs and the JSDoc state the rule per entry point.

What else does not change, on purpose.

  • Bun.Cookie.parse(x).value stays different from new Bun.CookieMap(x).get(name) for a value with an escape (b%20c and b c). Which of the two a parsed cookie should hold is a decision for a maintainer. This PR does not make it, and the docs do not promise either.
  • Parse and toString() are not a round trip for the attributes, on main and here. Cookie::parse fills Path=/ and SameSite=Lax and drops the attributes that it does not know. The docs now say so.
  • A CookieMap keeps one cookie for each name. To send several parsed cookies with one name, use headers.append("Set-Cookie", cookie.toString()). The docs name both ways.
  • Open deepEquals: compare FormData, Blob, File, Cookie and CookieMap by their native state #45613 adds Cookie::fieldsEqual. The mark is not a field that a user can read, so it stays out of that comparison.

The breaking part. A server that sets a parsed cookie and reads it on the next request now gets the decoded text from req.cookies.get(): s:abc for a cookie that was parsed from s%3Aabc. On 1.4.x it gets s%3Aabc, because the cookie went out as s%253Aabc. An escape that is not UTF-8 (caf%E9) reads back as U+FFFD, as it does for any cookie that a client sends in that form. Escapes for control characters and separators read back decoded too: %0d%0a gives CR LF, and %3B%20Secure gives ; Secure. The header holds only the escapes, and a cookie that is made from the text that was read is encoded again (a=%0D%0A). Of ten probe rows, five read back different from the .value that was set (one decoded, four with U+FFFD). On 1.4.3 none does. c.value = c.value or new Bun.Cookie(c.name, c.value) gives the 1.4.x bytes and read-back. I found no public code that does cookies.set(Bun.Cookie.parse(...)) on a server. #32926 changed the Expires bytes of Bun.Cookie: it merged as a plain fix, shipped in 1.4.0 and was added to the list in #28792 afterwards.

Escapes that are not UTF-8. A parsed caf%E9 is a cookie-value with well-formed escapes, so it is written back as parsed. On the next request Bun.CookieMap reads it as caf plus U+FFFD, where 1.4.x read caf%E9 back. The other choice is to keep such a value on the encode path. Then a server reads caf%E9 back, and the cookie leaves as caf%25E9. A client jar that saves toSetCookieHeaders() and parses the saved lines again (BingyanStudio/hust-sdk does this) then sends caf%25E9 to the issuer. With this PR it sends caf%E9 after any number of save and load cycles. On 1.4.3 it sends caf%2525E9 after three. I kept the forward, because these values come from other issuers. A reader that keeps a value raw when its escapes are not UTF-8 would give both. That is a decision about Bun.CookieMap, and the notes of #44664 name it.

Measurements. Release builds of main 2596ac5d39 and of this PR from one build directory. Instructions are gdb stepi counts of the 50th call. valgrind, perf and bloaty are not installed here.

  • sizeof(WebCore::Cookie): 72 to 72 bytes release, 80 to 80 bytes debug (static_assert). The bool sits in padding after m_partitioned.
  • Cookie::toString, cookie from new Bun.Cookie(name, value) (a=b / 19 characters / value with escapes): 1057 / 1984 / 1716 to 1059 / 1986 / 1718 instructions.
  • Cookie::toString, parsed cookie: 19 characters 1984 to 1668, express-session value 1995 to 1732, 4 KB valid value 109,093 to 76,550, value with one character outside the set 1610 to 1747, 4 KB value that fails at the last character 109,107 to 182,820. The guard is one scan before one append. A value that fails the guard is read a second time by the encoder: 14 of 75 characters on ten probe values.
  • Allocator calls per Cookie::toString (constructed a=b / 19 characters / with escapes, parsed 19 characters / with escapes): 4 / 7 / 6 / 7 / 6 to 4 / 7 / 6 / 6 / 6.
  • Cookie::parse (a=b / 19 characters / a=b%20c / with Path and HttpOnly): 500 / 560 / 533 / 2054 to 516 / 576 / 549 / 2071 instructions, 4 allocations in both. Cookie::create: 137 to 138. The value setter: 128 to 128.
  • Bytes (llvm-size -A, llvm-nm -S): .text +768. Cookie::appendTo 1444 to 2165, Cookie::parse 7228 to 7386, Cookie::create 890 to 894, Cookie::toString 285 to 285, JSC::encodeURIComponent 1468 to 1468, CookieMap::create 5096 to 5096.
  • Files changed: 7. Native code: Cookie.h and Cookie.cpp. No host function, no table row and no new API.
  • parse(pair).toString() gives back the pair for 7 of 10 probe pairs (1.4.3: 2). ${name}=${value} gives back the pair for 10 of 10 (1.4.3: 10). A client jar that parses each Set-Cookie and calls cookieMap.set(cookie) (BingyanStudio/hust-sdk, auth/cookie-manager.ts) writes back 6 of 8 upstream cookie shapes unchanged (1.4.3: 2). The Introduce CookieMap.toCookieRequestHeader() to simplify handling of cookies across multiple fetch() requests #18806 helper is byte-exact for 4 of 4 (1.4.3: 4).
  • Wire check against 1.4.3: a node:http server that writes toSetCookieHeaders() for parsed values with 0x01 and 0x7F answers 200 on both builds. A parsed caf\xE9 leaves Bun.serve as caf%C3%A9 on both. The Set-Cookie bytes for a parsed value with 0x01, a tab, 0x7F, U+00E9, a quote, a space, a comma and a backslash are identical, read from a raw socket.

Tests.

  • test/js/bun/cookie/cookie.test.ts: 19 new. A table of 14 pairs that are written back as parsed and a table of 24 pairs that keep the bytes of 1.4.3, each through Bun.Cookie.parse, new Bun.Cookie(string) and a subclass, from an 8-bit and from a 16-bit string. Then the fixed point after a second and a third parse, attribute setters, the value setter, the copies from a cookie string, the copies by value, the read-back of a stray % through Bun.CookieMap, and the test.failing that watches the Bun.CookieMap reader.
  • test/js/bun/cookie/cookie-map.test.ts: 2 new. map.set(cookie) with toSetCookieHeaders(), and map.set(name, value).
  • test/js/bun/http/bun-serve-cookies.test.ts: 1 new. req.cookies.set(Bun.Cookie.parse(upstream)) over HTTP, the unusual value, req.clone().cookies, and the read on the next request. TCP, TLS, HTTP/2 and HTTP/3 share one writer template (CookieMap.cpp:17-53), and the test uses TCP.
  • 11 of the 22 fail on a release build of main, and all pass on this PR (debug ASAN and release). I broke each clause once on a debug build and tests failed each time: the mark check in appendTo, the clear in the setter, the % look-ahead, the quote pair, a wider character set, the mark in parse.
  • Also run on the debug ASAN build: every file in test/js/bun/cookie/, test/js/bun/util/cookie.test.js, test/js/bun/http/bun-serve-routes.test.ts, test/js/bun/http/decodeURIComponentSIMD.test.ts, test/js/web/fetch/cookies.test.ts, test/regression/issue/22475.test.ts and test/bake/dev/request-cookies.test.ts. With the three files above that is 620 pass and 0 fail in 12 files. test/integration/bun-types/bun-types.test.ts passes 30 of 30. Linux x64 only.

Self-review. 11 concerns, all taken.

  • Opened as a breaking change: the label, the visible line, the migration and the release question.
  • Docs: the sentence about copies was false for a cookie string and is now the rule per entry point. The promises about what .value holds are gone. "The attributes are written as they are" was false and is now "does not percent-encode the name, Domain or Path". The rule is stated once, with the character set and the RFC section.
  • Docs and notes name both ways to send a parsed cookie again, the one cookie for each name of a CookieMap, and what parse does to the attributes.
  • new Bun.Cookie(cookie) is pinned by a test and listed above. I chose to pin it and not to carry the mark through the constructor: a copy rule there would depend on the content of the argument.
  • req.clone().cookies has a test row.
  • The % clause is tied to Bun.serve: keep a % that is not an escape in route params and cookies #44664 and http: consolidate the open fetch, Bun.serve, node:http, node:http2 and HTTP/3 pull requests #46528 by a test and by the list above.

[human-review] gate passed · iteration 0 · 7 files touched

fails on main (without fix)
ASAN without fix: 11 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/cookie/cookie-map.test.ts test/js/bun/cookie/cookie.test.ts test/js/bun/http/bun-serve-cookies.test.ts
bun test v1.4.4 (367d939d9)

test/js/bun/cookie/cookie-map.test.ts:
(pass) Bun.Cookie and Bun.CookieMap > can create a basic Cookie [2.92ms]
(pass) Bun.Cookie and Bun.CookieMap > can create a Cookie with options [2.92ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.toString() formats properly [4.37ms]
(pass) Bun.Cookie and Bun.CookieMap > can set Cookie expires as Date [58.52ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.isExpired() returns correct value [3.42ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.isExpired() gives Max-Age precedence over Expires [3.22ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.parse works with all attributes [2.73ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.parse keeps Expires when Max-Age comes first [2.94ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.parse takes the last value of a repeated attribute [1.32ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.parse reads every attribute 
... (truncated)

release without fix: all passed
bun test v1.4.4-canary.1 (e80650d6d)

test/js/bun/cookie/cookie-map.test.ts:
(pass) Bun.Cookie and Bun.CookieMap > can create a basic Cookie [0.14ms]
(pass) Bun.Cookie and Bun.CookieMap > can create a Cookie with options [0.05ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.toString() formats properly [39.52ms]
(pass) Bun.Cookie and Bun.CookieMap > can set Cookie expires as Date [157.28ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.isExpired() returns correct value [0.34ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.isExpired() gives Max-Age precedence over Expires [0.21ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.parse works with all attributes [0.17ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.parse keeps Expires when Max-Age comes first [1.46ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.parse takes the last value of a repeated attribute [0.06ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.parse reads every attribute in any order [0.22ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.serialize creates cookie string [0.07ms]
(pass) Bun.Cookie and Bun.CookieMap > can create an empty CookieMap [0.10ms]
(pass) Bun.Cookie and Bun.CookieMap > can create Co
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/cookie/cookie-map.test.ts test/js/bun/cookie/cookie.test.ts test/js/bun/http/bun-serve-cookies.test.ts
bun test v1.4.4 (367d939d9)

test/js/bun/cookie/cookie-map.test.ts:
(pass) Bun.Cookie and Bun.CookieMap > can create a basic Cookie [19.24ms]
(pass) Bun.Cookie and Bun.CookieMap > can create a Cookie with options [4.65ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.toString() formats properly [25.73ms]
(pass) Bun.Cookie and Bun.CookieMap > can set Cookie expires as Date [59.08ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.isExpired() returns correct value [3.22ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.isExpired() gives Max-Age precedence over Expires [4.65ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.parse works with all attributes [5.28ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.parse keeps Expires when Max-Age comes first [3.44ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.parse takes the last value of a repeated attribute [1.30ms]
(pass) Bun.Cookie and Bun.CookieMap > Cookie.parse reads every attribut
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 35866ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/326] gen cpp.rs (cppbind)
[2/326] gen the table of built-ins
builtins table: operands without uses: none
builtins table: 1811 host entries have a result that is not described; 88 properties that native code fills hold a value that is not described; a program in which a value may be anything holds what any host class needs: 0 modules, 15 functions
[3/17] cxx obj/src/jsc/bindings/BunObject.cpp.o
[4/17] cxx obj/codegen/BunAOTHostBindings.cpp.o
[5/17] cxx obj/src/jsc/bindings/ZigGlobalObject.cpp.o
[6/17] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-1.cpp.o
[7/17] cxx obj/unified/UnifiedSource-src_jsc_bindings-4.cpp.o
[8/17] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[9/17] cxx obj/unified/UnifiedSource-src_jsc_bindings-0.cpp.o
[10/17] gen builtin_sources.bin
[11/16] link bun-profile
ld.lld: warning: Linking two modules of different target triples: 'obj/src/simdutf_sys/bun-simdutf.cpp.o' is 'x86_64-pc-linux-gnu' whereas 'rust-target/x86_64-unknown-linux-gnu/deps/libbun_lint_plugins-87
... (truncated)
diff hotspot
docs/runtime/cookies.mdx                   |  38 ++++++
 packages/bun-types/bun.d.ts                |  37 +++++-
 src/jsc/bindings/Cookie.cpp                |  32 +++++-
 src/jsc/bindings/Cookie.h                  |   8 +-
 test/js/bun/cookie/cookie-map.test.ts      |  27 +++++
 test/js/bun/cookie/cookie.test.ts          | 179 +++++++++++++++++++++++++++++
 test/js/bun/http/bun-serve-cookies.test.ts |  39 +++++++
 7 files changed, 351 insertions(+), 9 deletions(-)

gate history · 2 passed · 0 rejected · iteration 0

evidence per changed file
file                                        reads  edits  tests
docs/runtime/cookies.mdx                        3      3     51
packages/bun-types/bun.d.ts                     2      3     48
src/jsc/bindings/Cookie.cpp                     4      5     54
src/jsc/bindings/Cookie.h                       3      3     52
test/js/bun/cookie/cookie-map.test.ts           1      1     14
test/js/bun/cookie/cookie.test.ts               5      7     32
test/js/bun/http/bun-serve-cookies.test.ts      2      3     26

Cookie::parse stores the value text of the cookie string, and
Cookie::appendTo percent-encoded every value. So toString() of a parsed
cookie encoded the value a second time: "a=b%20c" became "a=b%2520c".

Cookie::parse now marks the value as wire text. appendTo writes a marked
value back as it is when it is an RFC 6265 cookie-value in which every
"%" starts an escape, and percent-encodes every other value as before.
The value setter clears the mark. cookie.value does not change.
@robobun
robobun requested a review from alii as a code owner November 9, 2026 14:55
@github-actions github-actions Bot added the claude label Nov 9, 2026
@robobun

robobun commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:37 PM PT - Nov 9th, 2026

✅ @robobun, your commit e80650d6d1420d638a216eb906c3227b80a4ab2c passed in Build #139398! 🎉


🧪   To try this PR locally:

bunx bun-pr 48384

That installs a local version of the PR into your bun-48384 executable, so you can run:

bun-48384 --bun

@robobun

robobun commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced on Bun 1.4.3 (367d939d9). Main 2596ac5d39 has the same code.

Bun.Cookie.parse("a=b%20c").toString();
// "a=b%2520c; Path=/; SameSite=Lax" (expected "a=b%20c; Path=/; SameSite=Lax")

const map = new Bun.CookieMap();
map.set(Bun.Cookie.parse("sid=s%3Aabc; Path=/; HttpOnly"));
map.toSetCookieHeaders();
// ["sid=s%253Aabc; Path=/; HttpOnly; SameSite=Lax"]

On a build of this branch the two calls give a=b%20c; Path=/; SameSite=Lax and ["sid=s%3Aabc; Path=/; HttpOnly; SameSite=Lax"], and cookie.value is b%20c on both builds.

PR: #48384

@coderabbitai

coderabbitai Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Explain Change →

No actionable comments were generated in the recent review.

Walkthrough

Parsed cookies now retain qualifying wire-format values when serialized. Assigned text and parsed values that do not meet the cookie-value conditions are percent-encoded. Documentation and tests cover serialization, CookieMap, and request forwarding.

Changes

Cookie Serialization

Layer / File(s) Summary
Parsed-value serialization contract and implementation
src/jsc/bindings/Cookie.cpp, src/jsc/bindings/Cookie.h, packages/bun-types/bun.d.ts, docs/runtime/cookies.mdx
Parsing marks cookie values as wire text. Serialization preserves parsed values that meet the documented cookie-value conditions and percent-encodes other values. Assigning a value clears the wire-text state. Documentation describes serialization, parsing, and defaults.
Serialization and request integration tests
test/js/bun/cookie/cookie.test.ts, test/js/bun/cookie/cookie-map.test.ts, test/js/bun/http/bun-serve-cookies.test.ts
Tests cover parsed and assigned values, repeated parsing, copying, CookieMap serialization, and cookie forwarding through requests.

Suggested reviewers: jarred-sumner

Merge Risk: Minimal · up to e8065

Parsed cookies preserve qualifying wire-format values, while assigned values remain percent-encoded. No actionable merge-blocking issue was identified; merge after normal checks pass.

Pre-merge checks: Passed 4

Check name Status Explanation
Title check Passed The title names the main change: parsed cookie values should not be percent-encoded a second time by Bun.Cookie toString(). The changeset adds a wire-text mark in Cookie::parse, clears it in the value…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The description is detailed and substantially complete against the template. It has a "Problem" section, a "Fix" section, and a "Background" and "Downsides" section. The template's two required headin…
Full details: Title check

Explanation

The title names the main change: parsed cookie values should not be percent-encoded a second time by Bun.Cookie toString(). The changeset adds a wire-text mark in Cookie::parse, clears it in the value setter, and writes marked values verbatim in appendTo when they are valid cookie-values with well-formed %XX escapes. The title is specific, single-sentence, and matches the code, docs, and tests.

Full details: Description check

Explanation

The description is detailed and substantially complete against the template. It has a "Problem" section, a "Fix" section, and a "Background" and "Downsides" section. The template's two required headings are "What does this PR do?" and "How did you verify your code works?". The description does not use these headings. The content maps onto both. The "Problem" and "Fix" sections answer the first heading. The "Verified" bullet, the Tests section in the notes, and the measurements answer the second. It names the files and tests, reports the debug ASAN and release runs, and states that 11 of 22 new tests fail on main. It also covers the breaking change, the migration path, and the known limits. The main gap is that the template headings are absent. A reviewer can find the required information, but it is spread across sections and a long details block.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

Recent review info

Run configuration

Commits

Reviewing files that changed from the base of the PR and between 3025ba4 and e80650d.

Files selected for processing: 1

  • src/jsc/bindings/Cookie.cpp

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Beyond the inline findings, I also checked the mechanics of the new path and ruled them out: the % lookahead in isEncodedCookieValue is in bounds (i < size() holds before the subtraction), isCookieOctet rejects every code unit >= 0x7F so the Latin-1 and UTF-16 branches cannot pass non-ASCII through verbatim, and setValue is the only writer of m_value outside the constructor, so no copy or clone path carries a stale m_valueIsWireText. The test files were read, not run.

Extended reasoning...

The change adds a wire-text flag to Cookie set only by Cookie::parse and cleared by setValue, and makes appendTo emit a flagged value verbatim when it is an RFC 6265 cookie-value with well-formed %XX escapes; it touches the Set-Cookie output layer, so the guard is the security-relevant surface. The guard's bounds, encoding branches and flag lifecycle check out on reading; the posted findings concern the behavioral break for apps that round-trip parsed values, which is a design call for a maintainer.

Comment thread src/jsc/bindings/Cookie.cpp Resolved
Comment thread src/jsc/bindings/Cookie.cpp Resolved
The docs now say that the decoded text can differ from the value of the
Cookie that was set, and that an escape that is not valid UTF-8 is read
as U+FFFD. The table of values that toString() writes back as parsed
gets a row with such an escape.
Comment thread src/jsc/bindings/Cookie.cpp Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code review found no new issues

No new issues were found in this update; 1 finding from earlier reviews is still open above.

Still open from earlier reviews (1):

  • 🔴 src/jsc/bindings/Cookie.cpp:253 — Apps that set a parsed cookie with a %XX escape that is not UTF-8 (caf%E9, %fF) now read U+FFFD back from req.cookies.g…

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

The result was stored and tested by an empty branch that held a
two-line comment. The call now stands alone with one line that says why
it cannot fail.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant