diff --git a/README.zh-CN.md b/README.zh-CN.md index 5efed26..05ed963 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -28,6 +28,14 @@ ctx.cookies.set('key', 'value', options); 每次设置或读取 signed cookie 或者 encrypt cookie 的时候,会用 keys 进行加密。每次加密都通过 keys 数组的第一个 key 进行加密,解密会从先到后逐个 key 尝试解密。读取 signed cookie 时,如果发现不是用第一个 key 进行加密时,会更新签名为第一个 key 加密的值。读取 encrypt cookie 时不会进行更新操作。 +### `defaultCookieOptions` + +全局默认配置: + +- autoChips - `Boolean` 是否开启 [CHIPS](https://developers.google.com/privacy-sandbox/3pcd/chips#security_design) 的自动适配方案, +会自动给 Cookie 新增一个 `__Host` 为前缀的分区 Cookie,优先读取非分区 Cookie,读取失败则尝试读取 `__Host` 前缀的同名 Cookie 适配三方 Cookie 禁止逻辑。 +一旦配置 `autoChips=true`,那么会强制忽略 `partitioned`、`removeUnpartitioned` 参数。 + ## 设置 cookie 通过 `cookies.set(key, value, options)` 的方式来设置一个 cookie。其中 options 支持的参数有: diff --git a/index.d.ts b/index.d.ts index d258354..68dc32a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -5,6 +5,13 @@ * `import {CookieGetOptions, CookieSetOptions} from 'egg-cookies'`. */ declare namespace EggCookies { + interface DefaultCookieOptions { + /** + * Auto get and set `__Host` prefix cookie to adaptation CHIPS mode (The default value is false). + */ + autoChips?: boolean; + } + interface CookieGetOptions { /** * Whether to sign or not (The default value is true). @@ -72,7 +79,7 @@ declare namespace EggCookies { declare class EggCookies { - constructor(ctx?: any, keys?: any); + constructor(ctx?: any, keys?: any, opts?: EggCookies.DefaultCookieOptions); /** * Get the Egg's cookies by name with optional options. diff --git a/lib/cookies.js b/lib/cookies.js index 0c78534..0ec8c1b 100644 --- a/lib/cookies.js +++ b/lib/cookies.js @@ -23,6 +23,7 @@ class Cookies { this._keys = keys; // default cookie options this._defaultCookieOptions = defaultCookieOptions; + this._autoChips = defaultCookieOptions && defaultCookieOptions.autoChips; this.ctx = ctx; this.secure = this.ctx.secure; this.app = ctx.app; @@ -54,6 +55,15 @@ class Cookies { */ get(name, opts) { opts = opts || {}; + let value = this._get(name, opts); + if (value === undefined && this._autoChips) { + // try to read __Host-${name} prefix cookie + value = this._get(this._formatChipsCookieName(name), opts); + } + return value; + } + + _get(name, opts) { const signed = computeSigned(opts); const header = this.ctx.get('cookie'); @@ -93,6 +103,10 @@ class Cookies { set(name, value, opts) { opts = Object.assign({}, this._defaultCookieOptions, opts); + if (this._autoChips) { + opts.partitioned = false; + opts.removeUnpartitioned = false; + } const signed = computeSigned(opts); value = value || ''; if (!this.secure && opts.secure) { @@ -116,6 +130,7 @@ class Cookies { // fixed SameSite=None: Known Incompatible Clients const userAgent = this.ctx.get('user-agent'); let isSameSiteNone = false; + let autoChips = this._autoChips; if (opts.sameSite && typeof opts.sameSite === 'string' && opts.sameSite.toLowerCase() === 'none') { isSameSiteNone = true; if (opts.secure === false || !this.secure || (userAgent && !this.isSameSiteNoneCompatible(userAgent))) { @@ -124,10 +139,11 @@ class Cookies { isSameSiteNone = false; } } - if (opts.partitioned) { + if (autoChips || opts.partitioned) { // allow to set partitioned: secure=true and sameSite=none and chrome >= 118 if (!isSameSiteNone || opts.secure === false || !this.secure || (userAgent && !this.isPartitionedCompatible(userAgent))) { // Non-secure context or Incompatible clients, don't send partitioned property + autoChips = false; opts.partitioned = false; } } @@ -153,6 +169,24 @@ class Cookies { headers = ignoreCookiesByName(headers, removeUnpartitionedCookie.name); headers = pushCookie(headers, removeUnpartitionedCookie); } + } else if (autoChips) { + // add __Host-${name} prefix cookie + const newCookieName = this._formatChipsCookieName(name); + const newCookieOpts = Object.assign({}, opts, { + partitioned: true, + }); + const newPartitionedCookie = new Cookie(newCookieName, value, newCookieOpts); + // if user not set secure, reset secure to ctx.secure + if (opts.secure === undefined) newPartitionedCookie.attrs.secure = this.secure; + + headers = pushCookie(headers, newPartitionedCookie); + // signed + if (signed) { + newPartitionedCookie.value = value && this.keys.sign(newPartitionedCookie.toString()); + newPartitionedCookie.name += '.sig'; + headers = ignoreCookiesByName(headers, newPartitionedCookie.name); + headers = pushCookie(headers, newPartitionedCookie); + } } const cookie = new Cookie(name, value, opts); @@ -171,6 +205,10 @@ class Cookies { return this; } + _formatChipsCookieName(name) { + return `__Host-${name}`; + } + _parseChromiumAndMajorVersion(userAgent) { if (!this[PARSED_UA]) { this[PARSED_UA] = parseChromiumAndMajorVersion(userAgent); diff --git a/test/lib/cookies.test.js b/test/lib/cookies.test.js index d470af7..00d715a 100644 --- a/test/lib/cookies.test.js +++ b/test/lib/cookies.test.js @@ -658,4 +658,302 @@ describe('test/lib/cookies.test.js', () => { assert.equal(headers[1], 'foo.sig=ZWbaA4bWk8ByBuYVgfmJ2DMvhhS3sOctMbfXAQ2vnwI; path=/; secure; httponly'); }); }); + + describe('defaultCookieOptions.autoChips = true', () => { + it('should not send partitioned property on incompatible clients', () => { + const userAgents = [ + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML%2C like Gecko) Chrome/62.0.3202.94 Safari/537.36', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML%2C like Gecko) Chrome/52.0.2723.2 Safari/537.36', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/100.36 (KHTML, like Gecko) Safari/100.36', + ]; + for (const ua of userAgents) { + const cookies = Cookies({ + secure: true, + headers: { + 'user-agent': ua, + }, + }, { secure: true }, { autoChips: true, sameSite: 'None' }); + const opts = { + signed: 1, + }; + cookies.set('foo', 'hello', opts); + + assert(opts.signed === 1); + assert(opts.secure === undefined); + assert(cookies.ctx.response.headers['set-cookie'].join(';').match(/foo=hello/)); + for (const str of cookies.ctx.response.headers['set-cookie']) { + assert(!str.includes('partitioned')); + } + } + }); + + it('should not send partitioned property on Chrome < 118', () => { + const cookies = Cookies({ + secure: true, + headers: { + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.3945.29 Safari/537.36', + }, + }, { secure: true }, { autoChips: true, sameSite: 'None' }); + const opts = { + signed: 1, + }; + cookies.set('foo', 'hello', opts); + + assert(opts.signed === 1); + assert(opts.secure === undefined); + assert(cookies.ctx.response.headers['set-cookie'].join(';').match(/foo=hello/)); + for (const str of cookies.ctx.response.headers['set-cookie']) { + assert(str.includes('; path=/; samesite=none; secure; httponly')); + } + }); + + it('should send partitioned property on Chrome >= 118', () => { + let cookies = Cookies({ + secure: true, + headers: { + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.3945.29 Safari/537.36', + }, + }, { secure: true }, { autoChips: true, sameSite: 'None' }); + const opts = { + signed: 1, + }; + cookies.set('foo', 'hello', opts); + + assert(opts.signed === 1); + assert(opts.secure === undefined); + let setCookies = cookies.ctx.response.headers['set-cookie']; + assert.equal(setCookies.length, 4); + assert.equal(setCookies[0], '__Host-foo=hello; path=/; samesite=none; secure; httponly; partitioned'); + assert.equal(setCookies[1], '__Host-foo.sig=l0yCZaMfgLfAX4tuQ6mI4Hh3RBoeeWHrVHqNLlYx480; path=/; samesite=none; secure; httponly; partitioned'); + assert.equal(setCookies[2], 'foo=hello; path=/; samesite=none; secure; httponly'); + assert.equal(setCookies[3], 'foo.sig=ZWbaA4bWk8ByBuYVgfmJ2DMvhhS3sOctMbfXAQ2vnwI; path=/; samesite=none; secure; httponly'); + + cookies = Cookies({ + secure: true, + headers: { + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.3945.29 Safari/537.36', + }, + }, { secure: true }, { autoChips: true, sameSite: 'None' }); + cookies.set('foo', 'hello', opts); + + assert(opts.signed === 1); + assert(opts.secure === undefined); + setCookies = cookies.ctx.response.headers['set-cookie']; + assert.equal(setCookies[0], '__Host-foo=hello; path=/; samesite=none; secure; httponly; partitioned'); + assert.equal(setCookies[1], '__Host-foo.sig=l0yCZaMfgLfAX4tuQ6mI4Hh3RBoeeWHrVHqNLlYx480; path=/; samesite=none; secure; httponly; partitioned'); + assert.equal(setCookies[2], 'foo=hello; path=/; samesite=none; secure; httponly'); + assert.equal(setCookies[3], 'foo.sig=ZWbaA4bWk8ByBuYVgfmJ2DMvhhS3sOctMbfXAQ2vnwI; path=/; samesite=none; secure; httponly'); + + // empty user-agent + cookies = Cookies({ + secure: true, + headers: { + 'user-agent': '', + }, + }, { secure: true }, { autoChips: true, partitioned: true, removeUnpartitioned: true, sameSite: 'None' }); + cookies.set('foo', 'hello', opts); + + assert(opts.signed === 1); + assert(opts.secure === undefined); + setCookies = cookies.ctx.response.headers['set-cookie']; + assert.equal(setCookies[0], '__Host-foo=hello; path=/; samesite=none; secure; httponly; partitioned'); + assert.equal(setCookies[1], '__Host-foo.sig=l0yCZaMfgLfAX4tuQ6mI4Hh3RBoeeWHrVHqNLlYx480; path=/; samesite=none; secure; httponly; partitioned'); + assert.equal(setCookies[2], 'foo=hello; path=/; samesite=none; secure; httponly'); + assert.equal(setCookies[3], 'foo.sig=ZWbaA4bWk8ByBuYVgfmJ2DMvhhS3sOctMbfXAQ2vnwI; path=/; samesite=none; secure; httponly'); + + cookies = Cookies({ + secure: true, + headers: { + 'user-agent': '', + }, + }, { secure: true }, { autoChips: true }); + cookies.set('foo', 'hello', { + sameSite: 'None', + // ignore partitioned options + partitioned: true, + }); + + assert(opts.signed === 1); + assert(opts.secure === undefined); + setCookies = cookies.ctx.response.headers['set-cookie']; + assert.equal(setCookies[0], '__Host-foo=hello; path=/; samesite=none; secure; httponly; partitioned'); + assert.equal(setCookies[1], '__Host-foo.sig=l0yCZaMfgLfAX4tuQ6mI4Hh3RBoeeWHrVHqNLlYx480; path=/; samesite=none; secure; httponly; partitioned'); + assert.equal(setCookies[2], 'foo=hello; path=/; samesite=none; secure; httponly'); + assert.equal(setCookies[3], 'foo.sig=ZWbaA4bWk8ByBuYVgfmJ2DMvhhS3sOctMbfXAQ2vnwI; path=/; samesite=none; secure; httponly'); + + // read from cookie + cookies = Cookies({ + secure: true, + headers: { + cookie: '__Host-foo=hello; __Host-foo.sig=l0yCZaMfgLfAX4tuQ6mI4Hh3RBoeeWHrVHqNLlYx480; foo=hello; foo.sig=ZWbaA4bWk8ByBuYVgfmJ2DMvhhS3sOctMbfXAQ2vnwI', + }, + }, { secure: true }, { autoChips: true }); + assert.equal(cookies.get('foo'), 'hello'); + assert.equal(cookies.get('__Host-foo'), 'hello'); + cookies = Cookies({ + secure: true, + headers: { + cookie: '__Host-foo=hello; __Host-foo.sig=l0yCZaMfgLfAX4tuQ6mI4Hh3RBoeeWHrVHqNLlYx480', + }, + }, { secure: true }, { autoChips: true }); + assert.equal(cookies.get('foo', { signed: true }), 'hello'); + assert.equal(cookies.get('foo', { signed: false }), 'hello'); + assert.equal(cookies.get('foo'), 'hello'); + + cookies = Cookies({ + secure: true, + headers: { + cookie: '__Host-foo=hello; __Host-foo.sig=l0yCZaMfgLfAX4tuQ6mI4Hh3RBoeeWHrVHqNLlYx480-invalid', + }, + }, { secure: true }, { autoChips: true }); + assert.equal(cookies.get('foo', { signed: true }), undefined); + assert.equal(cookies.get('foo', { signed: false }), 'hello'); + assert.equal(cookies.get('foo'), undefined); + cookies = Cookies({ + secure: true, + headers: { + cookie: '__Host-foo=hello', + }, + }, { secure: true }, { autoChips: true }); + assert.equal(cookies.get('foo', { signed: true }), undefined); + assert.equal(cookies.get('foo', { signed: false }), 'hello'); + assert.equal(cookies.get('foo'), undefined); + cookies = Cookies({ + secure: true, + headers: { + cookie: '__Host-foo=hello; foo=', + }, + }, { secure: true }, { autoChips: true }); + assert.equal(cookies.get('foo', { signed: true }), undefined); + assert.equal(cookies.get('foo', { signed: false }), ''); + assert.equal(cookies.get('foo'), undefined); + }); + + it('should not send SameSite=none property on non-secure context', () => { + const cookies = Cookies({ + secure: false, + headers: { + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.3945.29 Safari/537.36', + }, + }, null, { autoChips: true, sameSite: 'None' }); + const opts = { + signed: 1, + }; + cookies.set('foo', 'hello', opts); + + assert(opts.signed === 1); + assert(opts.secure === undefined); + assert(cookies.ctx.response.headers['set-cookie'].join(';').match(/foo=hello/)); + for (const str of cookies.ctx.response.headers['set-cookie']) { + assert(str.includes('; path=/; httponly')); + } + }); + + it('should ignore remove unpartitioned property', () => { + const cookies = Cookies({ + secure: true, + headers: { + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.3945.29 Safari/537.36', + }, + }, { secure: true }, { autoChips: true, partitioned: true, removeUnpartitioned: true, sameSite: 'None' }); + const opts = { + signed: 1, + }; + cookies.set('foo', 'hello', opts); + + assert(opts.signed === 1); + assert(opts.secure === undefined); + const headers = cookies.ctx.response.headers['set-cookie']; + // console.log(headers); + assert.equal(headers.length, 4); + assert.equal(headers[0], '__Host-foo=hello; path=/; samesite=none; secure; httponly; partitioned'); + assert.equal(headers[1], '__Host-foo.sig=l0yCZaMfgLfAX4tuQ6mI4Hh3RBoeeWHrVHqNLlYx480; path=/; samesite=none; secure; httponly; partitioned'); + assert.equal(headers[2], 'foo=hello; path=/; samesite=none; secure; httponly'); + assert.equal(headers[3], 'foo.sig=ZWbaA4bWk8ByBuYVgfmJ2DMvhhS3sOctMbfXAQ2vnwI; path=/; samesite=none; secure; httponly'); + }); + + it('should ignore remove unpartitioned property when autoChips = true and signed = false', () => { + const cookies = Cookies({ + secure: true, + headers: { + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.3945.29 Safari/537.36', + }, + }, { secure: true }, { autoChips: true, partitioned: true, removeUnpartitioned: true, sameSite: 'None' }); + const opts = { + secure: true, + signed: false, + }; + cookies.set('foo', 'hello', opts); + + assert(opts.signed === false); + assert(opts.secure === true); + const headers = cookies.ctx.response.headers['set-cookie']; + // console.log(headers); + assert.equal(headers.length, 2); + assert.equal(headers[0], '__Host-foo=hello; path=/; samesite=none; secure; httponly; partitioned'); + assert.equal(headers[1], 'foo=hello; path=/; samesite=none; secure; httponly'); + }); + + it('should work with overwrite = true', () => { + const cookies = Cookies({ + secure: true, + headers: { + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.3945.29 Safari/537.36', + }, + }, { secure: true }, { autoChips: true, overwrite: true, sameSite: 'none' }); + const opts = { + signed: 1, + }; + cookies.set('foo', 'hello2222', opts); + cookies.set('foo', 'hello', opts); + + assert(opts.signed === 1); + assert(opts.secure === undefined); + const headers = cookies.ctx.response.headers['set-cookie']; + assert.equal(headers.length, 4); + assert.equal(headers[0], '__Host-foo=hello; path=/; samesite=none; secure; httponly; partitioned'); + assert.equal(headers[1], '__Host-foo.sig=l0yCZaMfgLfAX4tuQ6mI4Hh3RBoeeWHrVHqNLlYx480; path=/; samesite=none; secure; httponly; partitioned'); + assert.equal(headers[2], 'foo=hello; path=/; samesite=none; secure; httponly'); + assert.equal(headers[3], 'foo.sig=ZWbaA4bWk8ByBuYVgfmJ2DMvhhS3sOctMbfXAQ2vnwI; path=/; samesite=none; secure; httponly'); + }); + + it('should not set partitioned property when secure = false', () => { + const cookies = Cookies({ + secure: true, + headers: { + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.3945.29 Safari/537.36', + }, + }, { secure: true }, { autoChips: true, sameSite: 'None' }); + const opts = { + signed: 1, + secure: false, + }; + cookies.set('foo', 'hello', opts); + + assert(opts.signed === 1); + const headers = cookies.ctx.response.headers['set-cookie']; + assert.equal(headers.length, 2); + assert.equal(headers[0], 'foo=hello; path=/; httponly'); + assert.equal(headers[1], 'foo.sig=ZWbaA4bWk8ByBuYVgfmJ2DMvhhS3sOctMbfXAQ2vnwI; path=/; httponly'); + }); + + it('should not set partitioned property when sameSite != none', () => { + const cookies = Cookies({ + secure: true, + headers: { + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.3945.29 Safari/537.36', + }, + }, { secure: true }, { autoChips: true }); + const opts = { + signed: 1, + }; + cookies.set('foo', 'hello', opts); + + assert(opts.signed === 1); + const headers = cookies.ctx.response.headers['set-cookie']; + assert.equal(headers.length, 2); + assert.equal(headers[0], 'foo=hello; path=/; secure; httponly'); + assert.equal(headers[1], 'foo.sig=ZWbaA4bWk8ByBuYVgfmJ2DMvhhS3sOctMbfXAQ2vnwI; path=/; secure; httponly'); + }); + }); });