From cec65f81dbef8903d6630816003391b7e193bf5e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:20:42 +0000 Subject: [PATCH 1/3] Bun.Cookie: write the value of a parsed cookie back as it is 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. --- docs/runtime/cookies.mdx | 38 +++++ packages/bun-types/bun.d.ts | 37 ++++- src/jsc/bindings/Cookie.cpp | 35 +++- src/jsc/bindings/Cookie.h | 8 +- test/js/bun/cookie/cookie-map.test.ts | 27 ++++ test/js/bun/cookie/cookie.test.ts | 178 +++++++++++++++++++++ test/js/bun/http/bun-serve-cookies.test.ts | 39 +++++ 7 files changed, 353 insertions(+), 9 deletions(-) diff --git a/docs/runtime/cookies.mdx b/docs/runtime/cookies.mdx index 05e3e7074589..b7966ea14b3e 100644 --- a/docs/runtime/cookies.mdx +++ b/docs/runtime/cookies.mdx @@ -324,6 +324,17 @@ console.log(cookie.toString()); // => "session=abc123; Domain=example.com; Path=/admin; Expires=Wed, 19 Mar 2025 15:03:26 GMT; Secure; HttpOnly; SameSite=Strict" ``` +`toString()` percent-encodes the value the way [`encodeURIComponent`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent) does. It does not percent-encode the name, `Domain` or `Path`. + +```ts title="serialize-cookie-value.ts" icon="/icons/typescript.svg" +const cookie = new Bun.Cookie("greeting", "hello world; 100%"); + +console.log(cookie.toString()); +// => "greeting=hello%20world%3B%20100%25; Path=/; SameSite=Lax" +``` + +There is one exception. A cookie that was made from a cookie string (`Cookie.parse` or `new Bun.Cookie(cookieString)`), and whose `value` was not assigned since, keeps the value text of that string. This applies when the text is a `cookie-value` of [RFC 6265 section 4.1.1](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1) in which two hex digits follow every `%`. A `cookie-value` is printable ASCII other than space, `"`, `,`, `;` and `\`, optionally inside one pair of double quotes. `toString()` percent-encodes every other value. + #### `toJSON(): CookieInit` Converts the cookie to a plain object suitable for JSON serialization. @@ -365,6 +376,33 @@ console.log(cookie.secure); // true console.log(cookie.sameSite); // "lax" ``` +`toString()` writes the value of a parsed cookie back as it is in the string. The exact rule is in the `toString()` section above. A cookie string is read as it is. A value that is passed as a value is text, and `toString()` percent-encodes it: `new Bun.Cookie(name, value)`, `Cookie.from`, a `CookieInit` object, `cookies.set(name, value)` and an assignment to `cookie.value`. + +```ts title="parse-cookie-value.ts" icon="/icons/typescript.svg" +const cookie = Bun.Cookie.parse("session=s%3Aabc.def%2Fghi; Path=/; HttpOnly"); + +console.log(cookie.toString()); // "session=s%3Aabc.def%2Fghi; Path=/; HttpOnly; SameSite=Lax" + +// To send a parsed cookie again, append its string to the headers of a response +const headers = new Headers(); +headers.append("Set-Cookie", cookie.toString()); + +// or set the Cookie on a CookieMap, which keeps one cookie for each name +const cookies = new Bun.CookieMap(); +cookies.set(cookie); +console.log(cookies.toSetCookieHeaders()); // ["session=s%3Aabc.def%2Fghi; Path=/; HttpOnly; SameSite=Lax"] + +// A cookie string is read as it is +console.log(Bun.Cookie.parse(cookie.toString()).toString()); // "session=s%3Aabc.def%2Fghi; Path=/; HttpOnly; SameSite=Lax" + +// A value that is passed as a value is text, so it is percent-encoded +console.log(new Bun.Cookie(cookie.name, cookie.value).toString()); // "session=s%253Aabc.def%252Fghi; Path=/; SameSite=Lax" +``` + +A parsed cookie always has a `path` (`/` by default) and a `sameSite` (`lax` by default), and Bun drops the attributes that it does not know. So `toString()` can write attributes that differ from the string that was parsed. + +`Bun.CookieMap` percent-decodes the values of a `Cookie` request header. So after a client sends this cookie back, `req.cookies.get("session")` is `"s:abc.def/ghi"`. + #### `Cookie.from(name: string, value: string, options?: CookieInit): Cookie` Factory method to create a cookie. diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 8279a41e75f7..ed138e44df92 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -11135,7 +11135,10 @@ declare module "bun" { readonly name: string; /** - * The value of the cookie + * The value of the cookie. + * + * A value that you assign is text, and {@link Cookie.serialize} + * percent-encodes it. */ value: string; @@ -11185,7 +11188,16 @@ declare module "bun" { isExpired(): boolean; /** - * Serializes the cookie to a string + * Serializes the cookie to a string. + * + * The value is percent-encoded the way `encodeURIComponent` does, with + * one exception. A cookie that was made from a cookie string + * ({@link Cookie.parse} or `new Bun.Cookie(cookieString)`), and whose + * `value` was not assigned since, keeps the value text of that string. + * This applies when the text is a `cookie-value` of RFC 6265 section 4.1.1 + * in which two hex digits follow every `%`. A `cookie-value` is printable + * ASCII other than space, `"`, `,`, `;` and `\`, optionally inside one + * pair of double quotes. * * @example * ```ts @@ -11210,7 +11222,26 @@ declare module "bun" { toJSON(): CookieInit; /** - * Parses a serialized cookie string into a `Cookie` + * Parses a serialized cookie string into a `Cookie`. + * + * {@link Cookie.serialize} writes the value of a parsed cookie back as it + * is in the string (see there for the rule). A cookie string is read as it + * is. A value that is passed as a value is text and is percent-encoded: + * `new Bun.Cookie(name, value)`, {@link Cookie.from}, a {@link CookieInit} + * object, `CookieMap.set(name, value)` and an assignment to `value`. + * + * To send a parsed cookie again, use + * `headers.append("Set-Cookie", cookie.toString())` or + * `cookies.set(cookie)`. A `CookieMap` keeps one cookie for each name. + * + * A parsed cookie always has a `path` (`/` by default) and a `sameSite` + * (`lax` by default), and attributes that Bun does not know are dropped. + * + * @example + * ```ts + * const cookie = Bun.Cookie.parse("session=s%3Aabc; Path=/"); + * cookie.toString(); // "session=s%3Aabc; Path=/; SameSite=Lax" + * ``` * @param cookieString - A serialized cookie string, like `"name=value; Path=/"` */ static parse(cookieString: string): Cookie; diff --git a/src/jsc/bindings/Cookie.cpp b/src/jsc/bindings/Cookie.cpp index 0413c74abb80..a277a0ff3adc 100644 --- a/src/jsc/bindings/Cookie.cpp +++ b/src/jsc/bindings/Cookie.cpp @@ -149,7 +149,10 @@ ExceptionOr> Cookie::parse(StringView cookieString) } } - return Cookie::create(name, value, domain, path, expires, secure, sameSite, httpOnly, maxAge, partitioned); + auto cookie = Cookie::create(name, value, domain, path, expires, secure, sameSite, httpOnly, maxAge, partitioned); + if (!cookie.hasException()) + cookie.returnValue()->m_valueIsWireText = true; + return cookie; } bool Cookie::isExpired() const @@ -234,15 +237,37 @@ bool Cookie::isValidCookieDomain(const String& domain) return true; } +static inline bool isCookieOctet(char16_t c) +{ + return c == 0x21 || (c >= 0x23 && c <= 0x2B) || (c >= 0x2D && c <= 0x3A) || (c >= 0x3C && c <= 0x5B) || (c >= 0x5D && c <= 0x7E); +} +template +static bool isEncodedCookieValue(std::span value) +{ + // RFC 6265 4.1.1: cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE ) + if (value.size() >= 2 && value.front() == '"' && value.back() == '"') + value = value.subspan(1, value.size() - 2); + for (size_t i = 0; i < value.size(); i++) { + if (!isCookieOctet(value[i])) return false; + // CookieMap reads a '%' that does not start an escape back as U+FFFD, so a value with one stays percent-encoded. + if (value[i] == '%' && (value.size() - i < 3 || !isASCIIHexDigit(value[i + 1]) || !isASCIIHexDigit(value[i + 2]))) return false; + } + return true; +} + void Cookie::appendTo(JSC::VM& vm, StringBuilder& builder) const { // Name=Value is the basic format builder.append(m_name); builder.append('='); - auto result = encodeURIComponent(vm, m_value, builder); - if (result.hasException()) { - // m_value contained unpaired surrogate. oops! - // fortunately, this never happens because the string has already had invalid surrogate pairs converted to the replacement character + if (m_valueIsWireText && (m_value.is8Bit() ? isEncodedCookieValue(m_value.span8()) : isEncodedCookieValue(m_value.span16()))) { + builder.append(m_value); + } else { + auto result = encodeURIComponent(vm, m_value, builder); + if (result.hasException()) { + // m_value contained unpaired surrogate. oops! + // fortunately, this never happens because the string has already had invalid surrogate pairs converted to the replacement character + } } // Add domain if present diff --git a/src/jsc/bindings/Cookie.h b/src/jsc/bindings/Cookie.h index ef66916a109c..fb967bd78ed6 100644 --- a/src/jsc/bindings/Cookie.h +++ b/src/jsc/bindings/Cookie.h @@ -62,7 +62,11 @@ class Cookie : public RefCounted { const String& name() const { return m_name; } const String& value() const { return m_value; } - void setValue(const String& value) { m_value = value; } + void setValue(const String& value) + { + m_value = value; + m_valueIsWireText = false; + } const String& domain() const { return m_domain; } ExceptionOr setDomain(const String& domain) @@ -133,6 +137,8 @@ class Cookie : public RefCounted { bool m_httpOnly = false; double m_maxAge = std::numeric_limits::quiet_NaN(); bool m_partitioned = false; + // Set by parse(): m_value is the value text of a cookie string, which appendTo() writes back as it is when it is a valid cookie-value. + bool m_valueIsWireText = false; }; } // namespace WebCore diff --git a/test/js/bun/cookie/cookie-map.test.ts b/test/js/bun/cookie/cookie-map.test.ts index 3cccb2a4b065..98e961b62f10 100644 --- a/test/js/bun/cookie/cookie-map.test.ts +++ b/test/js/bun/cookie/cookie-map.test.ts @@ -359,6 +359,33 @@ describe("Bun.Cookie and Bun.CookieMap", () => { ] `); }); + + test("CookieMap writes a parsed Cookie back with the value text it has", () => { + const map = new Bun.CookieMap(); + const cookie = Bun.Cookie.parse("sid=s%3Aabc.def%2Fghi; Path=/; HttpOnly"); + map.set(cookie); + + expect(map.get("sid")).toBe("s%3Aabc.def%2Fghi"); + expect(map.toJSON()).toEqual({ sid: "s%3Aabc.def%2Fghi" }); + expect(map.toSetCookieHeaders()).toEqual(["sid=s%3Aabc.def%2Fghi; Path=/; HttpOnly; SameSite=Lax"]); + + // The map holds the Cookie itself. A value that is assigned afterwards is text. + cookie.value = "x y"; + expect(map.toSetCookieHeaders()).toEqual(["sid=x%20y; Path=/; HttpOnly; SameSite=Lax"]); + }); + + test("CookieMap percent-encodes a value that is set as a string", () => { + const cookie = Bun.Cookie.parse("sid=s%3Aabc"); + const byNameAndValue = new Bun.CookieMap(); + byNameAndValue.set(cookie.name, cookie.value); + const byObject = new Bun.CookieMap(); + byObject.set(cookie.toJSON()); + + expect([byNameAndValue.toSetCookieHeaders(), byObject.toSetCookieHeaders()]).toEqual([ + ["sid=s%253Aabc; Path=/; SameSite=Lax"], + ["sid=s%253Aabc; Path=/; SameSite=Lax"], + ]); + }); }); describe("Cookie name field is immutable", () => { diff --git a/test/js/bun/cookie/cookie.test.ts b/test/js/bun/cookie/cookie.test.ts index 073dfc71f17f..66dbb388adc9 100644 --- a/test/js/bun/cookie/cookie.test.ts +++ b/test/js/bun/cookie/cookie.test.ts @@ -1,3 +1,4 @@ +import { jscDescribe } from "bun:jsc"; import { afterAll, describe, expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; @@ -476,6 +477,183 @@ describe("cookie name parsing from Cookie header", () => { }); }); +// The value of a cookie made from a cookie string is the text of that string. +// toString() must not percent-encode that text a second time. +describe("the value of a cookie made from a cookie string", () => { + // Each value is an RFC 6265 cookie-value in which every "%" starts an escape. + const keptPairs = [ + "a=b%20c", + "sid=s%3Aabc", + "connect.sid=s%3Aabc.def%2Fghi", + "j=%7B%22a%22%3A1%7D", + "u=%e8%af%bb", + "x=%fF", + "tok=YWJj+/==", + "k=a=b==", + "p=!#$&'()*+-./:<>?@[]^_`{|}~", + 'q="abc"', + 'q="b%20c"', + 'e=""', + "n=", + ]; + // [pair, the pair that toString() writes]. These values have a "%" that does not start an escape. + const strayPercentPairs: [string, string][] = [ + ["a=100%", "a=100%25"], + ["a=50%-off", "a=50%25-off"], + ["a=%zz", "a=%25zz"], + ["a=a%2", "a=a%252"], + ["a=%", "a=%25"], + ["a=x%%20y", "a=x%25%2520y"], + ["a=a%2Gb", "a=a%252Gb"], + ["a=%G2", "a=%25G2"], + ["a=%u00e9", "a=%25u00e9"], + ['a="a%"', "a=%22a%25%22"], + ]; + // [pair, the pair that toString() writes]. These values keep the bytes that Bun 1.4.3 writes. + const encodedPairs: [string, string][] = [ + // a character that is not a cookie-octet + ["a=b c", "a=b%20c"], + ["a=b\tc", "a=b%09c"], + ["a=x,y", "a=x%2Cy"], + ["a=b\\c", "a=b%5Cc"], + ["a=b\x01c", "a=b%01c"], + ["a=b\x7Fc", "a=b%7Fc"], + ["a=caf\u00e9", "a=caf%C3%A9"], + // a double quote that is not one of a pair around cookie-octets + ['a="b c"', "a=%22b%20c%22"], + ['a="abc', "a=%22abc"], + ['a=abc"', "a=abc%22"], + ['a=b"c', "a=b%22c"], + ['a="', "a=%22"], + ...strayPercentPairs, + // one such character sends the whole value to the encoder + ["a=b%20c d", "a=b%2520c%20d"], + ["a=caf\u00e9%20x", "a=caf%C3%A9%2520x"], + ]; + const valueOf = (pair: string) => pair.slice(pair.indexOf("=") + 1); + // The same text in a string that JavaScriptCore holds in 16 bits. The value of the cookie is then 16-bit too. + const as16Bit = (text: string) => { + const wide = Buffer.from(text, "utf16le").toString("utf16le"); + expect(jscDescribe(wide)).toContain("8Bit:(0)"); + return wide; + }; + + describe.each([ + ["Bun.Cookie.parse(string)", (cookieString: string) => Bun.Cookie.parse(cookieString)], + ["new Bun.Cookie(string)", (cookieString: string) => new Bun.Cookie(cookieString)], + [ + "new (class extends Bun.Cookie)(string)", + (cookieString: string) => new (class extends Bun.Cookie {})(cookieString), + ], + ])("%s", (_, make) => { + test.each([ + ["an 8-bit string", (text: string) => text], + ["a 16-bit string", as16Bit], + ])("writes a cookie-value from %s back as it is", (_, encoding) => { + const actual = keptPairs.map(pair => { + const cookie = make(encoding(pair + "; Path=/p; HttpOnly")); + return [cookie.value, cookie.toJSON().value, cookie.toString(), cookie.serialize(), `${cookie}`]; + }); + expect(actual).toEqual( + keptPairs.map(pair => { + const written = pair + "; Path=/p; HttpOnly; SameSite=Lax"; + return [valueOf(pair), valueOf(pair), written, written, written]; + }), + ); + }); + + test.each([ + ["an 8-bit string", (text: string) => text], + ["a 16-bit string", as16Bit], + ])("percent-encodes every other value from %s", (_, encoding) => { + const actual = encodedPairs.map(([pair]) => { + const cookie = make(encoding(pair)); + return [cookie.value, cookie.toString()]; + }); + expect(actual).toEqual( + encodedPairs.map(([pair, written]) => [valueOf(pair), written + "; Path=/; SameSite=Lax"]), + ); + }); + }); + + test("toString() does not change when its output is parsed again", () => { + const pairs = [...keptPairs, ...encodedPairs.map(([pair]) => pair)]; + const first = pairs.map(pair => Bun.Cookie.parse(pair).toString()); + const third = first.map(header => Bun.Cookie.parse(Bun.Cookie.parse(header).toString()).toString()); + expect(third).toEqual(first); + }); + + test("a change to an attribute does not change how the value is written", () => { + const cookie = Bun.Cookie.parse("a=b%20c"); + cookie.domain = "example.com"; + cookie.path = "/x"; + cookie.maxAge = 60; + cookie.secure = true; + cookie.httpOnly = true; + cookie.partitioned = true; + cookie.sameSite = "strict"; + expect(cookie.toString()).toBe( + "a=b%20c; Domain=example.com; Path=/x; Max-Age=60; Secure; HttpOnly; Partitioned; SameSite=Strict", + ); + }); + + test("a value that is assigned is text, so toString() percent-encodes it", () => { + const cookie = Bun.Cookie.parse("a=b%20c"); + cookie.value = "x y"; + expect(cookie.toString()).toBe("a=x%20y; Path=/; SameSite=Lax"); + cookie.value = "b%20c"; + expect(cookie.toString()).toBe("a=b%2520c; Path=/; SameSite=Lax"); + + const assignedToItself = Bun.Cookie.parse("a=b%20c"); + assignedToItself.value = assignedToItself.value; + expect(assignedToItself.toString()).toBe("a=b%2520c; Path=/; SameSite=Lax"); + }); + + test("a copy made from a cookie string is written like its source", () => { + const cookie = Bun.Cookie.parse("a=b%20c; Path=/p; HttpOnly"); + expect([ + Bun.Cookie.parse(cookie.toString()).toString(), + new Bun.Cookie(`${cookie.name}=${cookie.value}`).toString(), + ]).toEqual(["a=b%20c; Path=/p; HttpOnly; SameSite=Lax", "a=b%20c; Path=/; SameSite=Lax"]); + }); + + test("a copy made from the name and the value is text, so toString() percent-encodes it", () => { + const cookie = Bun.Cookie.parse("a=b%20c"); + const copies = [ + new Bun.Cookie("a", "b%20c"), + new Bun.Cookie(cookie.name, cookie.value), + Bun.Cookie.from(cookie.name, cookie.value), + new Bun.Cookie(cookie.toJSON()), + // The constructor reads a Cookie the way it reads any object with a name and a value. + new Bun.Cookie(cookie), + ]; + expect(copies.map(copy => copy.toString())).toEqual([ + "a=b%2520c; Path=/; SameSite=Lax", + "a=b%2520c; Path=/; SameSite=Lax", + "a=b%2520c; Path=/; SameSite=Lax", + "a=b%2520c; Path=/; SameSite=Lax", + "a=b%2520c; Path=/; SameSite=Lax", + ]); + }); + + // Bun.CookieMap reads a "%" that does not start an escape as U+FFFD. So toString() percent-encodes + // a parsed value that has one, and Bun.CookieMap reads the text back. + test("Bun.CookieMap reads back a written value with a '%' that does not start an escape", () => { + const readBack = strayPercentPairs.map(([pair]) => { + const written = Bun.Cookie.parse(pair).toString().split(";")[0]; + return new Bun.CookieMap(written).get("a"); + }); + expect(readBack).toEqual(strayPercentPairs.map(([pair]) => valueOf(pair))); + }); + + // When this test passes, Bun.CookieMap keeps such a "%", and toString() can write these values as parsed. + // Then remove the "%" check from isEncodedCookieValue in Cookie.cpp and move strayPercentPairs into keptPairs. + test.failing("Bun.CookieMap reads a '%' that does not start an escape without U+FFFD", () => { + const read = strayPercentPairs.map(([pair]) => new Bun.CookieMap(pair).get("a")!); + expect(read.filter(value => value.includes("\uFFFD"))).toEqual([]); + }); +}); + test("throws when a cookie is too long to serialize, and Bun.serve responds without it", async () => { await using proc = Bun.spawn({ cmd: [ diff --git a/test/js/bun/http/bun-serve-cookies.test.ts b/test/js/bun/http/bun-serve-cookies.test.ts index 50cdd3f07746..dfa7b9eb5671 100644 --- a/test/js/bun/http/bun-serve-cookies.test.ts +++ b/test/js/bun/http/bun-serve-cookies.test.ts @@ -592,3 +592,42 @@ describe("Direct usage of Bun.Cookie and Bun.CookieMap", () => { expect(parsed.value).toBe("value"); }); }); + +describe("a Cookie from Bun.Cookie.parse that is set on req.cookies", () => { + it("is sent with the value text it has", async () => { + const upstream = "connect.sid=s%3Aabc.def%2Fghi; Path=/; HttpOnly; SameSite=Lax"; + // These characters are not cookie-octets, so the value stays percent-encoded on the wire. + const unusual = 'a=b\x01c\td\x7F\u00e9"x y,z\\'; + await using server = Bun.serve({ + port: 0, + routes: { + "/forward": req => { + req.cookies.set(Bun.Cookie.parse(upstream)); + req.cookies.set(Bun.Cookie.parse(unusual)); + return new Response("ok"); + }, + // The cookies of a cloned request hold the same Cookie. + "/clone": req => { + req.cookies.set(Bun.Cookie.parse(upstream)); + return Response.json(req.clone().cookies.toSetCookieHeaders()); + }, + "/read": req => new Response(req.cookies.get("connect.sid")), + }, + }); + + const forwarded = await fetch(new URL("/forward", server.url)); + expect(forwarded.headers.getSetCookie()).toEqual([ + upstream, + "a=b%01c%09d%7F%C3%A9%22x%20y%2Cz%5C; Path=/; SameSite=Lax", + ]); + + const cloned = await fetch(new URL("/clone", server.url)); + expect(await cloned.json()).toEqual([upstream]); + + // A client sends the pair back, and req.cookies percent-decodes a Cookie header. + const read = await fetch(new URL("/read", server.url), { + headers: { "Cookie": "connect.sid=s%3Aabc.def%2Fghi" }, + }); + expect(await read.text()).toBe("s:abc.def/ghi"); + }); +}); From 3025ba40f73dc4eee76cb2ec2330e85c38fae82f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:14:33 +0000 Subject: [PATCH 2/3] Bun.Cookie docs: say that req.cookies.get() returns the decoded text 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. --- docs/runtime/cookies.mdx | 2 +- test/js/bun/cookie/cookie.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/runtime/cookies.mdx b/docs/runtime/cookies.mdx index b7966ea14b3e..04c7a58a921b 100644 --- a/docs/runtime/cookies.mdx +++ b/docs/runtime/cookies.mdx @@ -401,7 +401,7 @@ console.log(new Bun.Cookie(cookie.name, cookie.value).toString()); // "session=s A parsed cookie always has a `path` (`/` by default) and a `sameSite` (`lax` by default), and Bun drops the attributes that it does not know. So `toString()` can write attributes that differ from the string that was parsed. -`Bun.CookieMap` percent-decodes the values of a `Cookie` request header. So after a client sends this cookie back, `req.cookies.get("session")` is `"s:abc.def/ghi"`. +`Bun.CookieMap` percent-decodes the values of a `Cookie` request header. So after a client sends this cookie back, `req.cookies.get("session")` is `"s:abc.def/ghi"`. That text can differ from the `value` of the `Cookie` that was set. An escape that is not valid UTF-8 is read as the Unicode replacement character (`\uFFFD`). #### `Cookie.from(name: string, value: string, options?: CookieInit): Cookie` diff --git a/test/js/bun/cookie/cookie.test.ts b/test/js/bun/cookie/cookie.test.ts index 66dbb388adc9..f7a5e1c83927 100644 --- a/test/js/bun/cookie/cookie.test.ts +++ b/test/js/bun/cookie/cookie.test.ts @@ -488,6 +488,7 @@ describe("the value of a cookie made from a cookie string", () => { "j=%7B%22a%22%3A1%7D", "u=%e8%af%bb", "x=%fF", + "l=caf%E9", "tok=YWJj+/==", "k=a=b==", "p=!#$&'()*+-./:<>?@[]^_`{|}~", From e80650d6d1420d638a216eb906c3227b80a4ab2c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:22:38 +0000 Subject: [PATCH 3/3] Cookie::appendTo: do not keep the unused result of encodeURIComponent 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. --- src/jsc/bindings/Cookie.cpp | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/jsc/bindings/Cookie.cpp b/src/jsc/bindings/Cookie.cpp index a277a0ff3adc..db2a33f4b0ba 100644 --- a/src/jsc/bindings/Cookie.cpp +++ b/src/jsc/bindings/Cookie.cpp @@ -263,11 +263,8 @@ void Cookie::appendTo(JSC::VM& vm, StringBuilder& builder) const if (m_valueIsWireText && (m_value.is8Bit() ? isEncodedCookieValue(m_value.span8()) : isEncodedCookieValue(m_value.span16()))) { builder.append(m_value); } else { - auto result = encodeURIComponent(vm, m_value, builder); - if (result.hasException()) { - // m_value contained unpaired surrogate. oops! - // fortunately, this never happens because the string has already had invalid surrogate pairs converted to the replacement character - } + // This fails only for an unpaired surrogate, which m_value never has. + encodeURIComponent(vm, m_value, builder); } // Add domain if present