Skip to content

Commit d673c09

Browse files
Dextheking1Muse Spark
andcommitted
Consolidate cookie parsers into parseCookiePairs
Replaces the diverging `parseCookie` (utils/cookie.ts) and the private `parseCookieHeader` (utils/request.ts) with a single parser returning ordered `[name, value]` pairs, as requested in the linked issue. The consolidated parser keeps `parseCookie`'s splitting, quote-stripping and URL-decoding, gains `parseCookieHeader`'s `Set-Cookie` mode and array input, and additionally: - returns nameless segments as `['', segment]` (dropped by `filterCookies`, always `[Filtered]` in span attributes), - splits on `;` with or without a trailing space, - drops known `Set-Cookie` attribute names (`Max-Age`, `Path`, `Expires`, `Domain`, ...) in cookie mode so they are no longer reported as cookies. Fixes #24501 Co-Authored-By: Muse Spark <noreply@meta.com>
1 parent beca211 commit d673c09

8 files changed

Lines changed: 218 additions & 74 deletions

File tree

‎packages/core/src/integrations/requestdata.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { Event } from '../types/event';
77
import type { IntegrationFn } from '../types/integration';
88
import type { QueryParams, RequestEventData } from '../types/request';
99
import type { StreamedSpanJSON } from '../types/span';
10-
import { parseCookie } from '../utils/cookie';
10+
import { parseCookiePairs } from '../utils/cookie';
1111
import { SENSITIVE_COOKIE_NAME_SNIPPETS } from '../utils/data-collection/filtering-snippets';
1212
import { filterKeyValueData } from '../utils/data-collection/filterKeyValueData';
1313
import { filterQueryParams } from '../utils/data-collection/filterQueryParams';
@@ -245,7 +245,7 @@ function extractNormalizedRequestData(
245245
}
246246

247247
if (include.cookies) {
248-
const cookies = normalizedRequest.cookies || (headers?.cookie ? parseCookie(headers.cookie) : undefined);
248+
const cookies = normalizedRequest.cookies || (headers?.cookie ? parseCookieRecord(headers.cookie) : undefined);
249249
requestData.cookies = cookies || {};
250250
}
251251

@@ -260,6 +260,19 @@ function extractNormalizedRequestData(
260260
return requestData;
261261
}
262262

263+
function parseCookieRecord(cookieString: string): Record<string, string> {
264+
const parsed: Record<string, string> = {};
265+
266+
for (const [name, value] of parseCookiePairs(cookieString)) {
267+
// only assign once
268+
if (name !== '' && !(name in parsed)) {
269+
parsed[name] = value;
270+
}
271+
}
272+
273+
return parsed;
274+
}
275+
263276
function resolveFilteringBehavior(isIncluded: boolean, behavior: CollectBehavior): CollectBehavior {
264277
return isIncluded && behavior === false ? true : behavior;
265278
}

‎packages/core/src/utils/cookie.ts‎

Lines changed: 44 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -28,51 +28,68 @@
2828
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
2929
*/
3030

31+
// `Set-Cookie` attributes are metadata, not cookies. Response cookie strings handed to
32+
// the `Cookie`-mode parser may still carry them, so they are dropped by name.
33+
const SET_COOKIE_ATTRIBUTES = new Set([
34+
'expires',
35+
'max-age',
36+
'domain',
37+
'path',
38+
'secure',
39+
'httponly',
40+
'samesite',
41+
'partitioned',
42+
]);
43+
3144
/**
32-
* Parses a cookie string
45+
* Parses a `Cookie` or `Set-Cookie` header value into ordered `[name, value]` pairs.
46+
*
47+
* In `Set-Cookie` mode each header value carries a single cookie, so only the segment
48+
* before the first `;` is parsed. Otherwise every `;`-separated segment is a pair, with
49+
* known `Set-Cookie` attributes dropped by name.
50+
*
51+
* Segments without `=` (e.g. a bare token or a flag like `Secure`) are returned as
52+
* `['', segment]`; values are unquoted and URL-decoded.
3353
*/
34-
export function parseCookie(str: string): Record<string, string> {
35-
const obj: Record<string, string> = {};
36-
let index = 0;
54+
export function parseCookiePairs(value: string | string[], setCookie = false): [string, string][] {
55+
const pairs: [string, string][] = [];
3756

38-
while (index < str.length) {
39-
const eqIdx = str.indexOf('=', index);
40-
41-
// no more cookie pairs
42-
if (eqIdx === -1) {
43-
break;
57+
for (const headerValue of Array.isArray(value) ? value : [value]) {
58+
if (typeof headerValue !== 'string' || headerValue === '') {
59+
continue;
4460
}
4561

46-
let endIdx = str.indexOf(';', index);
62+
const segments = setCookie ? [headerValue.split(';')[0]!] : headerValue.split(';');
4763

48-
if (endIdx === -1) {
49-
endIdx = str.length;
50-
} else if (endIdx < eqIdx) {
51-
// backtrack on prior semicolon
52-
index = str.lastIndexOf(';', eqIdx - 1) + 1;
53-
continue;
54-
}
64+
for (let segment of segments) {
65+
segment = segment.trim();
5566

56-
const key = str.slice(index, eqIdx).trim();
67+
if (segment === '') {
68+
continue;
69+
}
5770

58-
// only assign once
59-
if (undefined === obj[key]) {
60-
let val = str.slice(eqIdx + 1, endIdx).trim();
71+
const eqIdx = segment.indexOf('=');
72+
const name = (eqIdx === -1 ? '' : segment.slice(0, eqIdx)).trim();
73+
let val = (eqIdx === -1 ? segment : segment.slice(eqIdx + 1)).trim();
6174

6275
// quoted values
6376
if (val.charCodeAt(0) === 0x22) {
6477
val = val.slice(1, -1);
6578
}
6679

6780
try {
68-
obj[key] = val.indexOf('%') !== -1 ? decodeURIComponent(val) : val;
81+
val = val.indexOf('%') !== -1 ? decodeURIComponent(val) : val;
6982
} catch {
70-
obj[key] = val;
83+
// keep the raw value
7184
}
72-
}
7385

74-
index = endIdx + 1;
86+
if (!setCookie && SET_COOKIE_ATTRIBUTES.has(name.toLowerCase())) {
87+
continue;
88+
}
89+
90+
pairs.push([name, val]);
91+
}
7592
}
7693

77-
return obj;
94+
return pairs;
7895
}

‎packages/core/src/utils/data-collection/filterCookies.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { CollectBehavior } from '../../types/datacollection';
2-
import { parseCookie } from '../cookie';
2+
import { parseCookiePairs } from '../cookie';
33
import { FILTERED_VALUE as FILTERED, SENSITIVE_COOKIE_NAME_SNIPPETS } from './filtering-snippets';
44
import { filterKeyValueData } from './filterKeyValueData';
55

@@ -8,14 +8,23 @@ import { filterKeyValueData } from './filterKeyValueData';
88
*
99
* When individual cookies can be parsed, each key-value pair is filtered
1010
* independently. When parsing fails, the entire string is replaced with `[Filtered]`.
11+
* A nameless segment is dropped: a record key cannot carry a `[Filtered]` marker
12+
* without leaking the bare token.
1113
*/
1214
export function filterCookies(cookieString: string, behavior: CollectBehavior): Record<string, string> | string {
1315
if (behavior === false) {
1416
return {};
1517
}
1618

1719
try {
18-
const parsed = parseCookie(cookieString);
20+
const parsed: Record<string, string> = {};
21+
22+
for (const [name, value] of parseCookiePairs(cookieString)) {
23+
// only assign once
24+
if (name !== '' && !(name in parsed)) {
25+
parsed[name] = value;
26+
}
27+
}
1928

2029
if (Object.keys(parsed).length === 0) {
2130
return {};

‎packages/core/src/utils/request.ts‎

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { WebFetchHeaders, WebFetchRequest } from '../types/webfetchapi';
88
import { debug } from './debug-logger';
99
import { FILTERED_VALUE, SENSITIVE_COOKIE_NAME_SNIPPETS } from './data-collection/filtering-snippets';
1010
import { shouldFilterDataKey } from './data-collection/filterKeyValueData';
11+
import { parseCookiePairs } from './cookie';
1112
import { safeUnref } from './timer';
1213
import { getUrlQuery } from './url';
1314

@@ -303,13 +304,17 @@ export function httpHeadersToSpanAttributes(
303304
continue;
304305
}
305306

306-
const cookies = parseCookieHeader(value, lowerKey === 'set-cookie');
307+
const cookies = parseCookiePairs(value, lowerKey === 'set-cookie');
307308
spanAttributes[`${prefix}${lowerKey}`] = cookies.length
308-
? cookies.map(([cookieKey, cookieValue]) =>
309-
shouldFilterDataKey(cookieKey, cookieBehavior, SENSITIVE_COOKIE_NAME_SNIPPETS)
309+
? cookies.map(([cookieKey, cookieValue]) => {
310+
// A nameless segment's bare token is its value; no denylist could match it, so it is always filtered.
311+
if (cookieKey === '') {
312+
return FILTERED_VALUE;
313+
}
314+
return shouldFilterDataKey(cookieKey, cookieBehavior, SENSITIVE_COOKIE_NAME_SNIPPETS)
310315
? `${cookieKey}=${FILTERED_VALUE}`
311-
: `${cookieKey}=${cookieValue}`,
312-
)
316+
: `${cookieKey}=${cookieValue}`;
317+
})
313318
: [FILTERED_VALUE];
314319
} else {
315320
if (headerBehavior === false) {
@@ -338,24 +343,6 @@ export function httpHeadersToSpanAttributes(
338343
return spanAttributes;
339344
}
340345

341-
function parseCookieHeader(value: string | string[], isSetCookie: boolean): [string, string][] {
342-
// Set-Cookie: one cookie per value, with attributes ("name=value; HttpOnly; Secure")
343-
// Cookie: multiple cookies separated by "; " ("cookie1=value1; cookie2=value2")
344-
const cookies = (Array.isArray(value) ? value : [value]).flatMap(headerValue => {
345-
if (typeof headerValue !== 'string' || headerValue === '') {
346-
return [];
347-
}
348-
return isSetCookie ? [headerValue.split(';')[0]!] : headerValue.split('; ');
349-
});
350-
351-
return cookies.map(cookie => {
352-
const equalSignIndex = cookie.indexOf('=');
353-
return equalSignIndex !== -1
354-
? [cookie.substring(0, equalSignIndex), cookie.substring(equalSignIndex + 1)]
355-
: [cookie, ''];
356-
});
357-
}
358-
359346
/** Extract the query params from an URL. */
360347
export function extractQueryParamsFromUrl(url: string): string | undefined {
361348
// url is path and query string

‎packages/core/test/lib/integrations/requestdata.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ function baseEvent(overrides: Partial<Event> = {}): Event {
3232
};
3333
}
3434

35-
/** Rich normalized request (Cookie header only — tests `parseCookie` path). */
35+
/** Rich normalized request (Cookie header only — tests `parseCookiePairs` path). */
3636
function richNormalizedRequest() {
3737
return {
3838
method: 'POST',

‎packages/core/test/lib/utils/cookie.test.ts‎

Lines changed: 113 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,40 +29,136 @@
2929
*/
3030

3131
import { describe, expect, it } from 'vitest';
32-
import { parseCookie } from '../../../src/utils/cookie';
32+
import { parseCookiePairs } from '../../../src/utils/cookie';
3333

34-
describe('parseCookie(str)', function () {
35-
it('should parse cookie string to object', function () {
36-
expect(parseCookie('foo=bar')).toEqual({ foo: 'bar' });
37-
expect(parseCookie('foo=123')).toEqual({ foo: '123' });
34+
describe('parseCookiePairs(value)', function () {
35+
it('should parse cookie string to ordered pairs', function () {
36+
expect(parseCookiePairs('foo=bar')).toEqual([['foo', 'bar']]);
37+
expect(parseCookiePairs('foo=123')).toEqual([['foo', '123']]);
38+
expect(parseCookiePairs('foo=bar; baz=raz')).toEqual([
39+
['foo', 'bar'],
40+
['baz', 'raz'],
41+
]);
3842
});
3943

4044
it('should ignore OWS', function () {
41-
expect(parseCookie('FOO = bar; baz = raz')).toEqual({ FOO: 'bar', baz: 'raz' });
45+
expect(parseCookiePairs('FOO = bar; baz = raz')).toEqual([
46+
['FOO', 'bar'],
47+
['baz', 'raz'],
48+
]);
4249
});
4350

4451
it('should parse cookie with empty value', function () {
45-
expect(parseCookie('foo= ; bar=')).toEqual({ foo: '', bar: '' });
52+
expect(parseCookiePairs('foo= ; bar=')).toEqual([
53+
['foo', ''],
54+
['bar', ''],
55+
]);
4656
});
4757

4858
it('should URL-decode values', function () {
49-
expect(parseCookie('foo="bar=123456789&name=Magic+Mouse"')).toEqual({ foo: 'bar=123456789&name=Magic+Mouse' });
59+
expect(parseCookiePairs('foo="bar=123456789&name=Magic+Mouse"')).toEqual([
60+
['foo', 'bar=123456789&name=Magic+Mouse'],
61+
]);
5062

51-
expect(parseCookie('email=%20%22%2c%3b%2f')).toEqual({ email: ' ",;/' });
63+
expect(parseCookiePairs('email=%20%22%2c%3b%2f')).toEqual([['email', ' ",;/']]);
5264
});
5365

5466
it('should return original value on escape error', function () {
55-
expect(parseCookie('foo=%1;bar=bar')).toEqual({ foo: '%1', bar: 'bar' });
67+
expect(parseCookiePairs('foo=%1;bar=bar')).toEqual([
68+
['foo', '%1'],
69+
['bar', 'bar'],
70+
]);
5671
});
5772

58-
it('should ignore cookies without value', function () {
59-
expect(parseCookie('foo=bar;fizz ; buzz')).toEqual({ foo: 'bar' });
60-
expect(parseCookie(' fizz; foo= bar')).toEqual({ foo: 'bar' });
73+
it('should keep duplicate cookies as ordered pairs', function () {
74+
expect(parseCookiePairs('foo=%1;bar=bar;foo=boo')).toEqual([
75+
['foo', '%1'],
76+
['bar', 'bar'],
77+
['foo', 'boo'],
78+
]);
6179
});
6280

63-
it('should ignore duplicate cookies', function () {
64-
expect(parseCookie('foo=%1;bar=bar;foo=boo')).toEqual({ foo: '%1', bar: 'bar' });
65-
expect(parseCookie('foo=false;bar=bar;foo=tre')).toEqual({ foo: 'false', bar: 'bar' });
66-
expect(parseCookie('foo=;bar=bar;foo=boo')).toEqual({ foo: '', bar: 'bar' });
81+
it('should return nameless segments with an empty name', function () {
82+
expect(parseCookiePairs('foo=bar;fizz ; buzz')).toEqual([
83+
['foo', 'bar'],
84+
['', 'fizz'],
85+
['', 'buzz'],
86+
]);
87+
expect(parseCookiePairs(' fizz; foo= bar')).toEqual([
88+
['', 'fizz'],
89+
['foo', 'bar'],
90+
]);
91+
});
92+
93+
it('should split on ";" even without a trailing space', function () {
94+
expect(parseCookiePairs('foo=bar;baz=raz')).toEqual([
95+
['foo', 'bar'],
96+
['baz', 'raz'],
97+
]);
98+
});
99+
100+
it('should skip empty segments', function () {
101+
expect(parseCookiePairs('foo=bar;;;baz=raz;')).toEqual([
102+
['foo', 'bar'],
103+
['baz', 'raz'],
104+
]);
105+
expect(parseCookiePairs('')).toEqual([]);
106+
});
107+
108+
it('should only split on the first "="', function () {
109+
expect(parseCookiePairs('data=base64==')).toEqual([['data', 'base64==']]);
110+
});
111+
112+
it('should accept an array of header values', function () {
113+
expect(parseCookiePairs(['foo=bar', 'baz=raz'])).toEqual([
114+
['foo', 'bar'],
115+
['baz', 'raz'],
116+
]);
117+
expect(parseCookiePairs(['foo=bar', '', 'baz=raz'])).toEqual([
118+
['foo', 'bar'],
119+
['baz', 'raz'],
120+
]);
121+
});
122+
123+
describe('Set-Cookie mode', function () {
124+
it('should only parse the first segment of each value', function () {
125+
expect(parseCookiePairs('sid=1; Max-Age=3600; Path=/', true)).toEqual([['sid', '1']]);
126+
expect(parseCookiePairs('theme=dark; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Domain=example.com', true)).toEqual([
127+
['theme', 'dark'],
128+
]);
129+
});
130+
131+
it('should parse one cookie per array value', function () {
132+
expect(parseCookiePairs(['theme=dark; HttpOnly', 'session=abc123; Secure'], true)).toEqual([
133+
['theme', 'dark'],
134+
['session', 'abc123'],
135+
]);
136+
});
137+
138+
it('should return nameless first segments with an empty name', function () {
139+
expect(parseCookiePairs('auth_required; HttpOnly', true)).toEqual([['', 'auth_required']]);
140+
});
141+
});
142+
143+
describe('Set-Cookie attribute handling', function () {
144+
it('should drop known Set-Cookie attributes by name', function () {
145+
expect(parseCookiePairs('sid=1; Max-Age=3600; Path=/')).toEqual([['sid', '1']]);
146+
expect(parseCookiePairs('theme=dark; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Domain=example.com')).toEqual([
147+
['theme', 'dark'],
148+
]);
149+
expect(parseCookiePairs('a=1; SameSite=Lax; Max-Age=60')).toEqual([['a', '1']]);
150+
});
151+
152+
it('should return bare flag attributes as nameless pairs (dropped or filtered downstream)', () => {
153+
expect(parseCookiePairs('a=1; Secure; HttpOnly')).toEqual([
154+
['a', '1'],
155+
['', 'Secure'],
156+
['', 'HttpOnly'],
157+
]);
158+
});
159+
160+
it('should match attribute names case-insensitively', function () {
161+
expect(parseCookiePairs('sid=1; max-age=3600; PATH=/')).toEqual([['sid', '1']]);
162+
});
67163
});
68164
});

0 commit comments

Comments
 (0)