diff --git a/README.md b/README.md index 09cb444..74f4b29 100644 --- a/README.md +++ b/README.md @@ -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 roundtrip-safe 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..b2f233e 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 (without % to preserve roundtrip) that need no URL encoding. + */ +const cookieOctetRegExp = /^[!#$&'()*+\-.\/0-9:<=>?@A-Z[\]\^_`a-z{|}~]*$/; + const __toString = Object.prototype.toString; const NullObject = /* @__PURE__ */ (() => { @@ -144,8 +149,7 @@ 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. - * - * @default encodeURIComponent + * The default function preserves roundtrip-safe cookie-octet values and uses `encodeURIComponent` otherwise. */ encode?: (str: string) => string; } @@ -157,7 +161,7 @@ export function stringifyCookie( cookie: Cookies, options?: StringifyOptions, ): string { - const enc = options?.encode || encodeURIComponent; + const enc = options?.encode || defaultEncode; const keys = Object.keys(cookie); let str = ""; @@ -298,7 +302,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 || defaultEncode; if (!cookieNameRegExp.test(cookie.name)) { throw new TypeError(`argument name is invalid: ${cookie.name}`); @@ -534,6 +538,13 @@ function decode(str: string): string { } } +/** + * URL-encode string value. Optimized to skip native call for roundtrip-safe cookie-octet values. + */ +function defaultEncode(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..f078ab7 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~" }); + }); + + 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..0e4c634 100644 --- a/src/stringify-cookie.spec.ts +++ b/src/stringify-cookie.spec.ts @@ -1,6 +1,38 @@ import { describe, expect, it } from "vitest"; import { stringifyCookie, parseCookie } from "./index.js"; +const cookieOctets = + "!#$&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + + "^_`abcdefghijklmnopqrstuvwxyz{|}~"; + +const bmpEncodingCases: Array<[string, string, string]> = []; + +for (let code = 0; code <= 0xffff; code++) { + // encodeURIComponent throws on unpaired surrogates. + if (code >= 0xd800 && code <= 0xdfff) continue; + + const value = String.fromCharCode(code); + const encoded = cookieOctets.includes(value) + ? value + : encodeURIComponent(value); + + bmpEncodingCases.push([ + `U+${code.toString(16).toUpperCase().padStart(4, "0")}`, + value, + `key=${encoded}`, + ]); +} + +const astralEncodingCases: Array<[string, string, string]> = [ + "😄", + "𝌆", + "𠜎", +].map((value) => [ + `U+${value.codePointAt(0)!.toString(16).toUpperCase()}`, + value, + `key=${encodeURIComponent(value)}`, +]); + describe("cookie.stringifyCookie", () => { it("should stringify object", () => { expect(stringifyCookie({ key: "value" })).toEqual("key=value"); @@ -28,14 +60,39 @@ 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"); + expect(stringifyCookie({ foo: "100%" })).toEqual("foo=100%25"); + }); + + it("should pass through roundtrip-safe cookie-octet values without encoding", () => { + const value = cookieOctets; + + expect(stringifyCookie({ foo: value })).toEqual(`foo=${value}`); }); + it.each(bmpEncodingCases)( + "should match default encoding for BMP char %s", + (_name, value, expected) => { + expect(stringifyCookie({ key: value })).toEqual(expected); + }, + ); + + it.each(astralEncodingCases)( + "should match default encoding for astral char %s", + (_name, value, expected) => { + expect(stringifyCookie({ key: value })).toEqual(expected); + }, + ); + it("should error on invalid keys", () => { expect(() => stringifyCookie({ "test=": "" })).toThrow( /cookie name is invalid/, @@ -114,6 +171,14 @@ describe("cookie.stringifyCookie", () => { expect(parseCookie(str)).toEqual(cookies); }); + it("should roundtrip percent-encoded-looking values", () => { + const cookies = { foo: "%20" }; + const str = stringifyCookie(cookies); + + expect(str).toEqual("foo=%2520"); + expect(parseCookie(str)).toEqual(cookies); + }); + it("should roundtrip empty values", () => { const cookies = { a: "", b: "value" }; const str = stringifyCookie(cookies); diff --git a/src/stringify-set-cookie.spec.ts b/src/stringify-set-cookie.spec.ts index 92a56ea..e9c2983 100644 --- a/src/stringify-set-cookie.spec.ts +++ b/src/stringify-set-cookie.spec.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from "vitest"; import * as cookie from "./index.js"; +const cookieOctets = + "!#$&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + + "^_`abcdefghijklmnopqrstuvwxyz{|}~"; + describe("cookie.stringifySetCookie", function () { it("should have backward compatible export", function () { expect(cookie.serialize).toBe(cookie.stringifySetCookie); @@ -10,10 +14,17 @@ 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", ); + expect(cookie.stringifySetCookie("foo", "100%")).toEqual("foo=100%25"); + }); + + it("should pass through roundtrip-safe cookie-octet values without encoding", function () { + const value = cookieOctets; + + expect(cookie.stringifySetCookie("foo", value)).toEqual(`foo=${value}`); }); it("should serialize empty value", function () {