@eggjs/cookies 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cookie.ts ADDED
@@ -0,0 +1,160 @@
1
+ import assert from 'node:assert';
2
+
3
+ /**
4
+ * RegExp to match field-content in RFC 7230 sec 3.2
5
+ *
6
+ * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]
7
+ * field-vchar = VCHAR / obs-text
8
+ * obs-text = %x80-FF
9
+ */
10
+ const fieldContentRegExp = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/; // eslint-disable-line no-control-regex
11
+
12
+ /**
13
+ * RegExp to match Same-Site cookie attribute value.
14
+ * https://en.wikipedia.org/wiki/HTTP_cookie#SameSite_cookie
15
+ */
16
+ const sameSiteRegExp = /^(?:none|lax|strict)$/i;
17
+
18
+ /**
19
+ * RegExp to match Priority cookie attribute value.
20
+ */
21
+ const PRIORITY_REGEXP = /^(?:low|medium|high)$/i;
22
+
23
+ export interface CookieSetOptions {
24
+ /**
25
+ * The path for the cookie to be set in
26
+ */
27
+ path?: string | null;
28
+ /**
29
+ * The domain for the cookie
30
+ */
31
+ domain?: string | (() => string);
32
+ /**
33
+ * Is overridable
34
+ */
35
+ overwrite?: boolean;
36
+ /**
37
+ * Is the same site
38
+ */
39
+ sameSite?: string | boolean;
40
+ /**
41
+ * Encrypt the cookie's value or not
42
+ */
43
+ encrypt?: boolean;
44
+ /**
45
+ * Max age for browsers
46
+ */
47
+ maxAge?: number;
48
+ /**
49
+ * Expire time
50
+ */
51
+ expires?: Date;
52
+ /**
53
+ * Is for http only
54
+ */
55
+ httpOnly?: boolean;
56
+ /**
57
+ * Encrypt the cookie's value or not
58
+ */
59
+ secure?: boolean;
60
+ /**
61
+ * Is it signed or not.
62
+ */
63
+ signed?: boolean | number;
64
+ /**
65
+ * Is it partitioned or not.
66
+ */
67
+ partitioned?: boolean;
68
+ /**
69
+ * Remove unpartitioned same name cookie or not.
70
+ */
71
+ removeUnpartitioned?: boolean;
72
+ /**
73
+ * The cookie priority.
74
+ */
75
+ priority?: 'low' | 'medium' | 'high' | 'LOW' | 'MEDIUM' | 'HIGH';
76
+ }
77
+
78
+ export class Cookie {
79
+ name: string;
80
+ value: string;
81
+ readonly attrs: CookieSetOptions;
82
+
83
+ constructor(name: string, value?: string | null, attrs?: CookieSetOptions) {
84
+ assert(fieldContentRegExp.test(name), 'argument name is invalid');
85
+ assert(!value || fieldContentRegExp.test(value), 'argument value is invalid');
86
+ this.name = name;
87
+ this.value = value ?? '';
88
+ this.attrs = mergeDefaultAttrs(attrs);
89
+ assert(!this.attrs.path || fieldContentRegExp.test(this.attrs.path),
90
+ 'argument option path is invalid');
91
+ if (typeof this.attrs.domain === 'function') {
92
+ this.attrs.domain = this.attrs.domain();
93
+ }
94
+ assert(!this.attrs.domain || fieldContentRegExp.test(this.attrs.domain),
95
+ 'argument option domain is invalid');
96
+ assert(!this.attrs.sameSite || this.attrs.sameSite === true || sameSiteRegExp.test(this.attrs.sameSite),
97
+ 'argument option sameSite is invalid');
98
+ assert(!this.attrs.priority || PRIORITY_REGEXP.test(this.attrs.priority),
99
+ 'argument option priority is invalid');
100
+ if (!value) {
101
+ this.attrs.expires = new Date(0);
102
+ // make sure maxAge is empty
103
+ this.attrs.maxAge = undefined;
104
+ }
105
+ }
106
+
107
+ toString() {
108
+ return this.name + '=' + this.value;
109
+ }
110
+
111
+ toHeader() {
112
+ let header = this.toString();
113
+ const attrs = this.attrs;
114
+ if (attrs.path) {
115
+ header += '; path=' + attrs.path;
116
+ }
117
+ const maxAge = typeof attrs.maxAge === 'string' ? parseInt(attrs.maxAge, 10) : attrs.maxAge;
118
+ // ignore 0, `session` and other invalid maxAge
119
+ if (maxAge) {
120
+ header += '; max-age=' + Math.round(maxAge / 1000);
121
+ attrs.expires = new Date(Date.now() + maxAge);
122
+ }
123
+ if (attrs.expires) {
124
+ header += '; expires=' + attrs.expires.toUTCString();
125
+ }
126
+ if (attrs.domain) {
127
+ header += '; domain=' + attrs.domain;
128
+ }
129
+ if (attrs.priority) {
130
+ header += '; priority=' + attrs.priority.toLowerCase();
131
+ }
132
+ if (attrs.sameSite) {
133
+ header += '; samesite=' + (attrs.sameSite === true ? 'strict' : attrs.sameSite.toLowerCase());
134
+ }
135
+ if (attrs.secure) {
136
+ header += '; secure';
137
+ }
138
+ if (attrs.httpOnly) {
139
+ header += '; httponly';
140
+ }
141
+ if (attrs.partitioned) {
142
+ header += '; partitioned';
143
+ }
144
+ return header;
145
+ }
146
+ }
147
+
148
+ function mergeDefaultAttrs(attrs?: CookieSetOptions) {
149
+ const merged = {
150
+ path: '/',
151
+ httpOnly: true,
152
+ secure: false,
153
+ overwrite: false,
154
+ sameSite: false,
155
+ partitioned: false,
156
+ priority: undefined,
157
+ ...attrs,
158
+ };
159
+ return merged;
160
+ }
package/src/cookies.ts ADDED
@@ -0,0 +1,311 @@
1
+ import assert from 'node:assert';
2
+ import { base64decode, base64encode } from 'utility';
3
+ import { isSameSiteNoneCompatible } from 'should-send-same-site-none';
4
+ import { Keygrip } from './keygrip.js';
5
+ import { Cookie, CookieSetOptions } from './cookie.js';
6
+ import { CookieError } from './error.js';
7
+
8
+ const keyCache = new Map<string[], Keygrip>();
9
+
10
+ export interface DefaultCookieOptions extends CookieSetOptions {
11
+ /**
12
+ * Auto get and set `_CHIPS-` prefix cookie to adaptation CHIPS mode (The default value is false).
13
+ */
14
+ autoChips?: boolean;
15
+ }
16
+
17
+ export interface CookieGetOptions {
18
+ /**
19
+ * Whether to sign or not (The default value is true).
20
+ */
21
+ signed?: boolean;
22
+ /**
23
+ * Encrypt the cookie's value or not (The default value is false).
24
+ */
25
+ encrypt?: boolean;
26
+ }
27
+
28
+ /**
29
+ * cookies for egg
30
+ * extend pillarjs/cookies, add encrypt and decrypt
31
+ */
32
+ export class Cookies {
33
+ readonly #keysArray: string[];
34
+ #keys: Keygrip;
35
+ readonly #defaultCookieOptions?: DefaultCookieOptions;
36
+ readonly #autoChips?: boolean;
37
+ readonly ctx: Record<string, any>;
38
+ readonly app: Record<string, any>;
39
+ readonly secure: boolean;
40
+ #parseChromiumResult?: ParseChromiumResult;
41
+
42
+ constructor(ctx: Record<string, any>, keys: string[], defaultCookieOptions?: DefaultCookieOptions) {
43
+ this.#keysArray = keys;
44
+ // default cookie options
45
+ this.#defaultCookieOptions = defaultCookieOptions;
46
+ this.#autoChips = defaultCookieOptions?.autoChips;
47
+ this.ctx = ctx;
48
+ this.secure = this.ctx.secure;
49
+ this.app = ctx.app;
50
+ }
51
+
52
+ get keys() {
53
+ if (!this.#keys) {
54
+ assert(Array.isArray(this.#keysArray), '.keys required for encrypt/sign cookies');
55
+ const cache = keyCache.get(this.#keysArray);
56
+ if (cache) {
57
+ this.#keys = cache;
58
+ } else {
59
+ this.#keys = new Keygrip(this.#keysArray);
60
+ keyCache.set(this.#keysArray, this.#keys);
61
+ }
62
+ }
63
+ return this.#keys;
64
+ }
65
+
66
+ /**
67
+ * get cookie value by name
68
+ * @param {String} name - cookie's name
69
+ * @param {Object} opts - cookies' options
70
+ * - {Boolean} signed - default to true
71
+ * - {Boolean} encrypt - default to false
72
+ * @return {String} value - cookie's value
73
+ */
74
+ get(name: string, opts: CookieGetOptions = {}): string | undefined {
75
+ let value = this._get(name, opts);
76
+ if (value === undefined && this.#autoChips) {
77
+ // try to read _CHIPS-${name} prefix cookie
78
+ value = this._get(this.#formatChipsCookieName(name), opts);
79
+ }
80
+ return value;
81
+ }
82
+
83
+ _get(name: string, opts: CookieGetOptions) {
84
+ const signed = computeSigned(opts);
85
+ const header: string = this.ctx.get('cookie');
86
+ if (!header) return;
87
+
88
+ const match = header.match(getPattern(name));
89
+ if (!match) return;
90
+
91
+ let value = match[1];
92
+ if (!opts.encrypt && !signed) return value;
93
+
94
+ // signed
95
+ if (signed) {
96
+ const sigName = name + '.sig';
97
+ const sigValue = this.get(sigName, { signed: false });
98
+ if (!sigValue) return;
99
+
100
+ const raw = name + '=' + value;
101
+ const index = this.keys.verify(raw, sigValue);
102
+ if (index < 0) {
103
+ // can not match any key, remove ${name}.sig
104
+ this.set(sigName, null, { path: '/', signed: false, overwrite: true });
105
+ return;
106
+ }
107
+ if (index > 0) {
108
+ // not signed by the first key, update sigValue
109
+ this.set(sigName, this.keys.sign(raw), { signed: false, overwrite: true });
110
+ }
111
+ return value;
112
+ }
113
+
114
+ // encrypt
115
+ value = base64decode(value, true, 'buffer') as string;
116
+ const res = this.keys.decrypt(value);
117
+ return res ? res.value.toString() : undefined;
118
+ }
119
+
120
+ set(name: string, value: string | null, opts?: CookieSetOptions) {
121
+ opts = {
122
+ ...this.#defaultCookieOptions,
123
+ ...opts,
124
+ };
125
+ const signed = computeSigned(opts);
126
+ value = value || '';
127
+ if (!this.secure && opts.secure) {
128
+ throw new CookieError('Cannot send secure cookie over unencrypted connection');
129
+ }
130
+
131
+ let headers: string[] = this.ctx.response.get('set-cookie') || [];
132
+ if (!Array.isArray(headers)) {
133
+ headers = [ headers ];
134
+ }
135
+
136
+ // encrypt
137
+ if (opts.encrypt) {
138
+ value = value && base64encode(this.keys.encrypt(value), true);
139
+ }
140
+
141
+ // http://browsercookielimits.squawky.net/
142
+ if (value.length > 4093) {
143
+ this.app.emit('cookieLimitExceed', { name, value, ctx: this.ctx });
144
+ }
145
+
146
+ // https://github.com/linsight/should-send-same-site-none
147
+ // fixed SameSite=None: Known Incompatible Clients
148
+ const userAgent: string | undefined = this.ctx.get('user-agent');
149
+ let isSameSiteNone = false;
150
+ // disable autoChips if partitioned enable
151
+ let autoChips = !opts.partitioned && this.#autoChips;
152
+ if (opts.sameSite && typeof opts.sameSite === 'string' && opts.sameSite.toLowerCase() === 'none') {
153
+ isSameSiteNone = true;
154
+ if (opts.secure === false || !this.secure || (userAgent && !this.isSameSiteNoneCompatible(userAgent))) {
155
+ // Non-secure context or Incompatible clients, don't send SameSite=None property
156
+ opts.sameSite = false;
157
+ isSameSiteNone = false;
158
+ }
159
+ }
160
+ if (autoChips || opts.partitioned) {
161
+ // allow to set partitioned: secure=true and sameSite=none and chrome >= 118
162
+ if (!isSameSiteNone || opts.secure === false || !this.secure || (userAgent && !this.isPartitionedCompatible(userAgent))) {
163
+ // Non-secure context or Incompatible clients, don't send partitioned property
164
+ autoChips = false;
165
+ opts.partitioned = false;
166
+ }
167
+ }
168
+
169
+ // remove unpartitioned same name cookie first
170
+ if (opts.partitioned && opts.removeUnpartitioned) {
171
+ const overwrite = opts.overwrite;
172
+ if (overwrite) {
173
+ opts.overwrite = false;
174
+ headers = ignoreCookiesByName(headers, name);
175
+ }
176
+ const removeCookieOpts = Object.assign({}, opts, {
177
+ partitioned: false,
178
+ });
179
+ const removeUnpartitionedCookie = new Cookie(name, '', removeCookieOpts);
180
+ // if user not set secure, reset secure to ctx.secure
181
+ if (opts.secure === undefined) {
182
+ removeUnpartitionedCookie.attrs.secure = this.secure;
183
+ }
184
+
185
+ headers = pushCookie(headers, removeUnpartitionedCookie);
186
+ // signed
187
+ if (signed) {
188
+ removeUnpartitionedCookie.name += '.sig';
189
+ headers = ignoreCookiesByName(headers, removeUnpartitionedCookie.name);
190
+ headers = pushCookie(headers, removeUnpartitionedCookie);
191
+ }
192
+ } else if (autoChips) {
193
+ // add _CHIPS-${name} prefix cookie
194
+ const newCookieName = this.#formatChipsCookieName(name);
195
+ const newCookieOpts = {
196
+ ...opts,
197
+ partitioned: true,
198
+ };
199
+ const newPartitionedCookie = new Cookie(newCookieName, value, newCookieOpts);
200
+ // if user not set secure, reset secure to ctx.secure
201
+ if (opts.secure === undefined) newPartitionedCookie.attrs.secure = this.secure;
202
+
203
+ headers = pushCookie(headers, newPartitionedCookie);
204
+ // signed
205
+ if (signed) {
206
+ newPartitionedCookie.value = value && this.keys.sign(newPartitionedCookie.toString());
207
+ newPartitionedCookie.name += '.sig';
208
+ headers = ignoreCookiesByName(headers, newPartitionedCookie.name);
209
+ headers = pushCookie(headers, newPartitionedCookie);
210
+ }
211
+ }
212
+
213
+ const cookie = new Cookie(name, value, opts);
214
+ // if user not set secure, reset secure to ctx.secure
215
+ if (opts.secure === undefined) {
216
+ cookie.attrs.secure = this.secure;
217
+ }
218
+ headers = pushCookie(headers, cookie);
219
+
220
+ // signed
221
+ if (signed) {
222
+ cookie.value = value && this.keys.sign(cookie.toString());
223
+ cookie.name += '.sig';
224
+ headers = pushCookie(headers, cookie);
225
+ }
226
+
227
+ this.ctx.set('set-cookie', headers);
228
+ return this;
229
+ }
230
+
231
+ #formatChipsCookieName(name: string) {
232
+ return `_CHIPS-${name}`;
233
+ }
234
+
235
+ #parseChromiumAndMajorVersion(userAgent: string) {
236
+ if (!this.#parseChromiumResult) {
237
+ this.#parseChromiumResult = parseChromiumAndMajorVersion(userAgent);
238
+ }
239
+ return this.#parseChromiumResult;
240
+ }
241
+
242
+ isSameSiteNoneCompatible(userAgent: string) {
243
+ // Chrome >= 80.0.0.0
244
+ const result = this.#parseChromiumAndMajorVersion(userAgent);
245
+ if (result.chromium) {
246
+ return result.majorVersion >= 80;
247
+ }
248
+ return isSameSiteNoneCompatible(userAgent);
249
+ }
250
+
251
+ isPartitionedCompatible(userAgent: string) {
252
+ // support: Chrome >= 114.0.0.0
253
+ // default enable: Chrome >= 118.0.0.0
254
+ // https://developers.google.com/privacy-sandbox/3pcd/chips
255
+ const result = this.#parseChromiumAndMajorVersion(userAgent);
256
+ if (result.chromium) {
257
+ return result.majorVersion >= 118;
258
+ }
259
+ return false;
260
+ }
261
+ }
262
+
263
+ interface ParseChromiumResult {
264
+ chromium: boolean;
265
+ majorVersion: number;
266
+ }
267
+
268
+ // https://github.com/linsight/should-send-same-site-none/blob/master/index.js#L86
269
+ function parseChromiumAndMajorVersion(userAgent: string): ParseChromiumResult {
270
+ const m = /Chrom[^ /]{1,100}\/(\d{1,100}?)\./.exec(userAgent);
271
+ if (!m) {
272
+ return { chromium: false, majorVersion: 0 };
273
+ }
274
+ // Extract digits from first capturing group.
275
+ return { chromium: true, majorVersion: parseInt(m[1]) };
276
+ }
277
+
278
+ const _patternCache = new Map<string, RegExp>();
279
+ function getPattern(name: string) {
280
+ const cache = _patternCache.get(name);
281
+ if (cache) {
282
+ return cache;
283
+ }
284
+ const reg = new RegExp(
285
+ '(?:^|;) *' +
286
+ name.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') +
287
+ '=([^;]*)',
288
+ );
289
+ _patternCache.set(name, reg);
290
+ return reg;
291
+ }
292
+
293
+ function computeSigned(opts: { encrypt?: boolean; signed?: boolean | number }) {
294
+ // encrypt default to false, signed default to true.
295
+ // disable singed when encrypt is true.
296
+ if (opts.encrypt) return false;
297
+ return opts.signed !== false;
298
+ }
299
+
300
+ function pushCookie(cookies: string[], cookie: Cookie) {
301
+ if (cookie.attrs.overwrite) {
302
+ cookies = ignoreCookiesByName(cookies, cookie.name);
303
+ }
304
+ cookies.push(cookie.toHeader());
305
+ return cookies;
306
+ }
307
+
308
+ function ignoreCookiesByName(cookies: string[], name: string) {
309
+ const prefix = `${name}=`;
310
+ return cookies.filter(c => !c.startsWith(prefix));
311
+ }
package/src/error.ts ADDED
@@ -0,0 +1,6 @@
1
+ export class CookieError extends Error {
2
+ constructor(message: string, options?: ErrorOptions) {
3
+ super(message, options);
4
+ this.name = this.constructor.name;
5
+ }
6
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './cookies.js';
2
+ export * from './cookie.js';
3
+ export * from './error.js';
4
+ export * from './keygrip.js';
package/src/keygrip.ts ADDED
@@ -0,0 +1,129 @@
1
+ import { debuglog } from 'node:util';
2
+ import crypto, { type Cipher } from 'node:crypto';
3
+ import assert from 'node:assert';
4
+
5
+ const debug = debuglog('@eggjs/cookies:keygrip');
6
+
7
+ const KEY_LEN = 32;
8
+ const IV_SIZE = 16;
9
+ const passwordCache = new Map();
10
+
11
+ const replacer: Record<string, string> = {
12
+ '/': '_',
13
+ '+': '-',
14
+ '=': '',
15
+ };
16
+
17
+ function constantTimeCompare(a: Buffer, b: Buffer) {
18
+ if (a.length !== b.length) {
19
+ return false;
20
+ }
21
+ return crypto.timingSafeEqual(a, b);
22
+ }
23
+
24
+ // patch from https://github.com/crypto-utils/keygrip
25
+
26
+ export class Keygrip {
27
+ readonly #keys: string[];
28
+ readonly #hash = 'sha256';
29
+ readonly #cipher = 'aes-256-cbc';
30
+
31
+ constructor(keys: string[]) {
32
+ assert(Array.isArray(keys) && keys.length > 0, 'keys must be provided and should be an array');
33
+ this.#keys = keys;
34
+ }
35
+
36
+ // encrypt a message
37
+ encrypt(data: string, key?: string) {
38
+ key = key || this.#keys[0];
39
+ const password = keyToPassword(key);
40
+ const cipher = crypto.createCipheriv(this.#cipher, password.key, password.iv);
41
+ return crypt(cipher, data);
42
+ }
43
+
44
+ // decrypt a single message
45
+ // returns false on bad decrypts
46
+ decrypt(data: string | Buffer): { value: Buffer, index: number } | false {
47
+ // decrypt every key
48
+ const keys = this.#keys;
49
+ for (let i = 0; i < keys.length; i++) {
50
+ const value = this.#decryptByKey(data, keys[i]);
51
+ if (value !== false) {
52
+ return { value, index: i };
53
+ }
54
+ }
55
+ return false;
56
+ }
57
+
58
+ #decryptByKey(data: string | Buffer, key: string) {
59
+ try {
60
+ const password = keyToPassword(key);
61
+ const cipher = crypto.createDecipheriv(this.#cipher, password.key, password.iv);
62
+ return crypt(cipher, data);
63
+ } catch (err: any) {
64
+ debug('crypt error: %s', err);
65
+ return false;
66
+ }
67
+ }
68
+
69
+ sign(data: string | Buffer, key?: string) {
70
+ // default to the first key
71
+ key = key || this.#keys[0];
72
+
73
+ // url safe base64
74
+ return crypto
75
+ .createHmac(this.#hash, key)
76
+ .update(data)
77
+ .digest('base64')
78
+ .replace(/\/|\+|=/g, x => {
79
+ return replacer[x];
80
+ });
81
+ }
82
+
83
+ verify(data: string, digest: string) {
84
+ const keys = this.#keys;
85
+ for (let i = 0; i < keys.length; i++) {
86
+ const key = keys[i];
87
+ if (constantTimeCompare(Buffer.from(digest), Buffer.from(this.sign(data, key)))) {
88
+ debug('data %s match key %s, index: %d', data, key, i);
89
+ return i;
90
+ }
91
+ }
92
+ return -1;
93
+ }
94
+ }
95
+
96
+ function crypt(cipher: Cipher, data: string | Buffer) {
97
+ const text = Buffer.isBuffer(data) ? cipher.update(data) : cipher.update(data, 'utf-8');
98
+ const pad = cipher.final();
99
+ return Buffer.concat([ text, pad ]);
100
+ }
101
+
102
+ function keyToPassword(key: string) {
103
+ if (passwordCache.has(key)) {
104
+ return passwordCache.get(key);
105
+ }
106
+
107
+ // Simulate EVP_BytesToKey.
108
+ // see https://github.com/nodejs/help/issues/1673#issuecomment-503222925
109
+ const bytes = Buffer.alloc(KEY_LEN + IV_SIZE);
110
+ let lastHash = null,
111
+ nBytes = 0;
112
+ while (nBytes < bytes.length) {
113
+ const hash = crypto.createHash('md5');
114
+ if (lastHash) hash.update(lastHash);
115
+ hash.update(key);
116
+ lastHash = hash.digest();
117
+ lastHash.copy(bytes, nBytes);
118
+ nBytes += lastHash.length;
119
+ }
120
+
121
+ // Use these for decryption.
122
+ const password = {
123
+ key: bytes.subarray(0, KEY_LEN),
124
+ iv: bytes.subarray(KEY_LEN, bytes.length),
125
+ };
126
+
127
+ passwordCache.set(key, password);
128
+ return password;
129
+ }