diff --git a/README.md b/README.md index 09cb444..2feb3c3 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ const cookieHeader = cookie.stringifyCookie({ a: "foo", b: "bar" }); #### Options -- `encode` Specifies the function to encode a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). Defaults to [`encodeURIComponent`](#encode-and-decode). +- `encode` Specifies the function to encode a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). Defaults to preserving valid cookie-octet values and using [`encodeURIComponent`](#encode-and-decode) otherwise. ### cookie.parseSetCookie(str, options) @@ -88,7 +88,7 @@ const setCookieHeader = cookie.stringifySetCookie({ #### Options -- `encode` Specifies the function to encode a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). Defaults to [`encodeURIComponent`](#encode-and-decode). +- `encode` Specifies the function to encode a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). Defaults to preserving valid cookie-octet values and using [`encodeURIComponent`](#encode-and-decode) otherwise. ## Cookie object @@ -178,7 +178,7 @@ More information about enforcement levels can be found in [the specification](ht Cookie accepts `encode` or `decode` options for processing a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). Since the value of a cookie has a limited character set (and must be a simple string), these functions are used to transform values into strings suitable for a cookies value. -The default `encode` function is the global `encodeURIComponent`. +The default `encode` function preserves valid RFC 6265 cookie-octet values and uses the global `encodeURIComponent` otherwise. The default `decode` function is the global `decodeURIComponent`, wrapped in a `try..catch`. If an error is thrown it will return the cookie's original value. If you provide your own encode/decode diff --git a/src/index.ts b/src/index.ts index a95a2e9..169953b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,6 +68,11 @@ const pathValueRegExp = /^[\u0020-\u003A\u003D-\u007E]*$/; */ const maxAgeRegExp = /^-?\d+$/; +/** + * RegExp to match RFC 6265 cookie-octet values that need no URL encoding. + */ +const cookieOctetRegExp = /^[!#$%&'()*+\-.\/0-9:<=>?@A-Z[\]\^_`a-z{|}~]*$/; + const __toString = Object.prototype.toString; const NullObject = /* @__PURE__ */ (() => { @@ -144,8 +149,9 @@ export interface StringifyOptions { * Specifies a function that will be used to encode a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). * Since value of a cookie has a limited character set (and must be a simple string), this function can be used to encode * a value into a string suited for a cookie's value, and should mirror `decode` when parsing. + * The default function preserves valid RFC 6265 cookie-octet values and uses `encodeURIComponent` otherwise. * - * @default encodeURIComponent + * @default encode */ encode?: (str: string) => string; } @@ -157,7 +163,7 @@ export function stringifyCookie( cookie: Cookies, options?: StringifyOptions, ): string { - const enc = options?.encode || encodeURIComponent; + const enc = options?.encode || encode; const keys = Object.keys(cookie); let str = ""; @@ -298,7 +304,7 @@ export function stringifySetCookie( ? _name : { ..._opts, name: _name, value: String(_val) }; const options = typeof _val === "object" ? _val : _opts; - const enc = options?.encode || encodeURIComponent; + const enc = options?.encode || encode; if (!cookieNameRegExp.test(cookie.name)) { throw new TypeError(`argument name is invalid: ${cookie.name}`); @@ -534,6 +540,13 @@ function decode(str: string): string { } } +/** + * URL-encode string value. Optimized to skip native call for RFC 6265 cookie-octet values. + */ +function encode(str: string): string { + return cookieOctetRegExp.test(str) ? str : encodeURIComponent(str); +} + /** * Determine if value is a Date. */ diff --git a/src/stringify-cookie.bench.ts b/src/stringify-cookie.bench.ts index 9da3464..a882aaf 100644 --- a/src/stringify-cookie.bench.ts +++ b/src/stringify-cookie.bench.ts @@ -10,6 +10,14 @@ describe("cookie.stringifyCookie", () => { cookie.stringifyCookie({ foo: "bar" }); }); + bench("rfc cookie-octets", () => { + cookie.stringifyCookie({ foo: "a=b+c/d?x%20" }); + }); + + bench("encode", () => { + cookie.stringifyCookie({ foo: "bar baz;%" }); + }); + bench("undefined values", () => { cookie.stringifyCookie({ foo: "bar", @@ -19,6 +27,14 @@ describe("cookie.stringifyCookie", () => { }); }); + bench("mixed encode", () => { + cookie.stringifyCookie({ + foo: "bar", + baz: "quux zap", + qux: "quux", + }); + }); + const cookies10 = genCookies(10); bench("10 cookies", () => { cookie.stringifyCookie(cookies10); diff --git a/src/stringify-cookie.spec.ts b/src/stringify-cookie.spec.ts index 9edcfde..11f89fa 100644 --- a/src/stringify-cookie.spec.ts +++ b/src/stringify-cookie.spec.ts @@ -28,12 +28,57 @@ describe("cookie.stringifyCookie", () => { expect(stringifyCookie({ a: "", b: "" })).toEqual("a=; b="); }); - it("should URL-encode values by default", () => { + it("should encode values with non-cookie-octet chars by default", () => { expect(stringifyCookie({ foo: "bar baz" })).toEqual("foo=bar%20baz"); - expect(stringifyCookie({ foo: "a=b" })).toEqual("foo=a%3Db"); expect(stringifyCookie({ foo: "hello;world" })).toEqual( "foo=hello%3Bworld", ); + expect(stringifyCookie({ foo: 'hello"world' })).toEqual( + "foo=hello%22world", + ); + expect(stringifyCookie({ foo: "foo,bar" })).toEqual("foo=foo%2Cbar"); + expect(stringifyCookie({ foo: "foo\\bar" })).toEqual("foo=foo%5Cbar"); + }); + + it("should pass through cookie-octet values by default", () => { + const value = + "!#$%&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + + "^_`abcdefghijklmnopqrstuvwxyz{|}~"; + + expect(stringifyCookie({ foo: value })).toEqual(`foo=${value}`); + }); + + it("should match cookie-octet default encoding", () => { + const cookieOctets = + "!#$%&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + + "^_`abcdefghijklmnopqrstuvwxyz{|}~"; + const mismatches: string[] = []; + + for (let code = 0; code <= 0xffff; code++) { + if (code >= 0xd800 && code <= 0xdfff) continue; + + const value = String.fromCharCode(code); + const actual = stringifyCookie({ key: value }); + const encoded = cookieOctets.includes(value) + ? value + : encodeURIComponent(value); + const expected = `key=${encoded}`; + + if (actual !== expected) { + mismatches.push(`${code}: ${actual} !== ${expected}`); + } + } + + for (const value of ["😄", "𝌆", "𠜎"]) { + const actual = stringifyCookie({ key: value }); + const expected = `key=${encodeURIComponent(value)}`; + + if (actual !== expected) { + mismatches.push(`${value}: ${actual} !== ${expected}`); + } + } + + expect(mismatches).toEqual([]); }); it("should error on invalid keys", () => { diff --git a/src/stringify-set-cookie.spec.ts b/src/stringify-set-cookie.spec.ts index 92a56ea..3b718f2 100644 --- a/src/stringify-set-cookie.spec.ts +++ b/src/stringify-set-cookie.spec.ts @@ -10,12 +10,20 @@ describe("cookie.stringifySetCookie", function () { expect(cookie.stringifySetCookie("foo", "bar")).toEqual("foo=bar"); }); - it("should URL-encode value", function () { + it("should encode values with non-cookie-octet chars", function () { expect(cookie.stringifySetCookie("foo", "bar +baz")).toEqual( "foo=bar%20%2Bbaz", ); }); + it("should pass through cookie-octet values", function () { + const value = + "!#$%&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + + "^_`abcdefghijklmnopqrstuvwxyz{|}~"; + + expect(cookie.stringifySetCookie("foo", value)).toEqual(`foo=${value}`); + }); + it("should serialize empty value", function () { expect(cookie.stringifySetCookie("foo", "")).toEqual("foo="); });