Skip to content

Commit ec8c8c6

Browse files
authored
feat(core): Parse individual cookies from cookie header (#18325)
Parse each individual cookie header and filter sensitive cookies to at least know which keys the cookie string included. Follow-up on #18311 Closes #18441
1 parent 2ad7ca1 commit ec8c8c6

File tree

10 files changed

+209
-31
lines changed

10 files changed

+209
-31
lines changed

packages/astro/src/server/middleware.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,10 @@ async function instrumentRequestStartHttpServerSpan(
219219
// This is here for backwards compatibility, we used to set this here before
220220
method,
221221
url: stripUrlQueryAndFragment(ctx.url.href),
222-
...httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers)),
222+
...httpHeadersToSpanAttributes(
223+
winterCGHeadersToDict(request.headers),
224+
getClient()?.getOptions().sendDefaultPii ?? false,
225+
),
223226
};
224227

225228
if (parametrizedRoute) {

packages/bun/src/integrations/bunserver.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
captureException,
44
continueTrace,
55
defineIntegration,
6+
getClient,
67
httpHeadersToSpanAttributes,
78
isURLObjectRelative,
89
parseStringToURLObject,
@@ -206,7 +207,10 @@ function wrapRequestHandler<T extends RouteHandler = RouteHandler>(
206207
routeName = route;
207208
}
208209

209-
Object.assign(attributes, httpHeadersToSpanAttributes(request.headers.toJSON()));
210+
Object.assign(
211+
attributes,
212+
httpHeadersToSpanAttributes(request.headers.toJSON(), getClient()?.getOptions().sendDefaultPii ?? false),
213+
);
210214

211215
isolationScope.setSDKProcessingMetadata({
212216
normalizedRequest: {

packages/cloudflare/src/request.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
captureException,
44
continueTrace,
55
flush,
6+
getClient,
67
getHttpSpanDetailsFromUrlObject,
78
httpHeadersToSpanAttributes,
89
parseStringToURLObject,
@@ -67,7 +68,13 @@ export function wrapRequestHandler(
6768
attributes['user_agent.original'] = userAgentHeader;
6869
}
6970

70-
Object.assign(attributes, httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers)));
71+
Object.assign(
72+
attributes,
73+
httpHeadersToSpanAttributes(
74+
winterCGHeadersToDict(request.headers),
75+
getClient()?.getOptions().sendDefaultPii ?? false,
76+
),
77+
);
7178

7279
attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server';
7380

packages/core/src/utils/request.ts

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -128,21 +128,29 @@ function getAbsoluteUrl({
128128
return undefined;
129129
}
130130

131-
// "-user" because otherwise it would match "user-agent"
132131
const SENSITIVE_HEADER_SNIPPETS = [
133132
'auth',
134133
'token',
135134
'secret',
136-
'cookie',
137-
'-user',
135+
'session', // for the user_session cookie
138136
'password',
137+
'passwd',
138+
'pwd',
139139
'key',
140140
'jwt',
141141
'bearer',
142142
'sso',
143143
'saml',
144+
'csrf',
145+
'xsrf',
146+
'credentials',
147+
// Always treat cookie headers as sensitive in case individual key-value cookie pairs cannot properly be extracted
148+
'set-cookie',
149+
'cookie',
144150
];
145151

152+
const PII_HEADER_SNIPPETS = ['x-forwarded-', '-user'];
153+
146154
/**
147155
* Converts incoming HTTP request headers to OpenTelemetry span attributes following semantic conventions.
148156
* Header names are converted to the format: http.request.header.<key>
@@ -152,6 +160,7 @@ const SENSITIVE_HEADER_SNIPPETS = [
152160
*/
153161
export function httpHeadersToSpanAttributes(
154162
headers: Record<string, string | string[] | undefined>,
163+
sendDefaultPii: boolean = false,
155164
): Record<string, string> {
156165
const spanAttributes: Record<string, string> = {};
157166

@@ -161,16 +170,29 @@ export function httpHeadersToSpanAttributes(
161170
return;
162171
}
163172

164-
const lowerCasedKey = key.toLowerCase();
165-
const isSensitive = SENSITIVE_HEADER_SNIPPETS.some(snippet => lowerCasedKey.includes(snippet));
166-
const normalizedKey = `http.request.header.${lowerCasedKey.replace(/-/g, '_')}`;
173+
const lowerCasedHeaderKey = key.toLowerCase();
174+
const isCookieHeader = lowerCasedHeaderKey === 'cookie' || lowerCasedHeaderKey === 'set-cookie';
175+
176+
if (isCookieHeader && typeof value === 'string' && value !== '') {
177+
// Set-Cookie: single cookie with attributes ("name=value; HttpOnly; Secure")
178+
// Cookie: multiple cookies separated by "; " ("cookie1=value1; cookie2=value2")
179+
const isSetCookie = lowerCasedHeaderKey === 'set-cookie';
180+
const semicolonIndex = value.indexOf(';');
181+
const cookieString = isSetCookie && semicolonIndex !== -1 ? value.substring(0, semicolonIndex) : value;
182+
const cookies = isSetCookie ? [cookieString] : cookieString.split('; ');
183+
184+
for (const cookie of cookies) {
185+
// Split only at the first '=' to preserve '=' characters in cookie values
186+
const equalSignIndex = cookie.indexOf('=');
187+
const cookieKey = equalSignIndex !== -1 ? cookie.substring(0, equalSignIndex) : cookie;
188+
const cookieValue = equalSignIndex !== -1 ? cookie.substring(equalSignIndex + 1) : '';
167189

168-
if (isSensitive) {
169-
spanAttributes[normalizedKey] = '[Filtered]';
170-
} else if (Array.isArray(value)) {
171-
spanAttributes[normalizedKey] = value.map(v => (v != null ? String(v) : v)).join(';');
172-
} else if (typeof value === 'string') {
173-
spanAttributes[normalizedKey] = value;
190+
const lowerCasedCookieKey = cookieKey.toLowerCase();
191+
192+
addSpanAttribute(spanAttributes, lowerCasedHeaderKey, lowerCasedCookieKey, cookieValue, sendDefaultPii);
193+
}
194+
} else {
195+
addSpanAttribute(spanAttributes, lowerCasedHeaderKey, '', value, sendDefaultPii);
174196
}
175197
});
176198
} catch {
@@ -180,6 +202,47 @@ export function httpHeadersToSpanAttributes(
180202
return spanAttributes;
181203
}
182204

205+
function normalizeAttributeKey(key: string): string {
206+
return key.replace(/-/g, '_');
207+
}
208+
209+
function addSpanAttribute(
210+
spanAttributes: Record<string, string>,
211+
headerKey: string,
212+
cookieKey: string,
213+
value: string | string[] | undefined,
214+
sendPii: boolean,
215+
): void {
216+
const normalizedKey = cookieKey
217+
? `http.request.header.${normalizeAttributeKey(headerKey)}.${normalizeAttributeKey(cookieKey)}`
218+
: `http.request.header.${normalizeAttributeKey(headerKey)}`;
219+
220+
const headerValue = handleHttpHeader(cookieKey || headerKey, value, sendPii);
221+
if (headerValue !== undefined) {
222+
spanAttributes[normalizedKey] = headerValue;
223+
}
224+
}
225+
226+
function handleHttpHeader(
227+
lowerCasedKey: string,
228+
value: string | string[] | undefined,
229+
sendPii: boolean,
230+
): string | undefined {
231+
const isSensitive = sendPii
232+
? SENSITIVE_HEADER_SNIPPETS.some(snippet => lowerCasedKey.includes(snippet))
233+
: [...PII_HEADER_SNIPPETS, ...SENSITIVE_HEADER_SNIPPETS].some(snippet => lowerCasedKey.includes(snippet));
234+
235+
if (isSensitive) {
236+
return '[Filtered]';
237+
} else if (Array.isArray(value)) {
238+
return value.map(v => (v != null ? String(v) : v)).join(';');
239+
} else if (typeof value === 'string') {
240+
return value;
241+
}
242+
243+
return undefined;
244+
}
245+
183246
/** Extract the query params from an URL. */
184247
export function extractQueryParamsFromUrl(url: string): string | undefined {
185248
// url is path and query string

packages/core/test/lib/utils/request.test.ts

Lines changed: 96 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,7 @@ describe('request utils', () => {
527527
'X-Forwarded-For': '192.168.1.1',
528528
};
529529

530-
const result = httpHeadersToSpanAttributes(headers);
530+
const result = httpHeadersToSpanAttributes(headers, true);
531531

532532
expect(result).toEqual({
533533
'http.request.header.host': 'example.com',
@@ -612,7 +612,7 @@ describe('request utils', () => {
612612
});
613613
});
614614

615-
describe('PII filtering', () => {
615+
describe('PII/Sensitive data filtering', () => {
616616
it('filters sensitive headers case-insensitively', () => {
617617
const headers = {
618618
AUTHORIZATION: 'Bearer secret-token',
@@ -625,12 +625,99 @@ describe('request utils', () => {
625625

626626
expect(result).toEqual({
627627
'http.request.header.content_type': 'application/json',
628-
'http.request.header.cookie': '[Filtered]',
628+
'http.request.header.cookie.session': '[Filtered]',
629629
'http.request.header.x_api_key': '[Filtered]',
630630
'http.request.header.authorization': '[Filtered]',
631631
});
632632
});
633633

634+
it('attaches and filters sensitive cookie headers', () => {
635+
const headers = {
636+
Cookie:
637+
'session=abc123; tracking=enabled; cookie-authentication-key-without-value; theme=dark; lang=en; user_session=xyz789; pref=1',
638+
};
639+
640+
const result = httpHeadersToSpanAttributes(headers);
641+
642+
expect(result).toEqual({
643+
'http.request.header.cookie.session': '[Filtered]',
644+
'http.request.header.cookie.tracking': 'enabled',
645+
'http.request.header.cookie.theme': 'dark',
646+
'http.request.header.cookie.lang': 'en',
647+
'http.request.header.cookie.user_session': '[Filtered]',
648+
'http.request.header.cookie.cookie_authentication_key_without_value': '[Filtered]',
649+
'http.request.header.cookie.pref': '1',
650+
});
651+
});
652+
653+
it('adds a filtered cookie header when cookie header is present, but has no valid key=value pairs', () => {
654+
const headers1 = { Cookie: ['key', 'val'] };
655+
const result1 = httpHeadersToSpanAttributes(headers1);
656+
expect(result1).toEqual({ 'http.request.header.cookie': '[Filtered]' });
657+
658+
const headers3 = { Cookie: '' };
659+
const result3 = httpHeadersToSpanAttributes(headers3);
660+
expect(result3).toEqual({ 'http.request.header.cookie': '[Filtered]' });
661+
});
662+
663+
it.each([
664+
['preferred-color-mode=light', { 'http.request.header.set_cookie.preferred_color_mode': 'light' }],
665+
['theme=dark; HttpOnly', { 'http.request.header.set_cookie.theme': 'dark' }],
666+
['session=abc123; Domain=example.com; HttpOnly', { 'http.request.header.set_cookie.session': '[Filtered]' }],
667+
['lang=en; Expires=Wed, 21 Oct 2025 07:28:00 GMT', { 'http.request.header.set_cookie.lang': 'en' }],
668+
['pref=1; Max-Age=3600', { 'http.request.header.set_cookie.pref': '1' }],
669+
['color=blue; Path=/dashboard', { 'http.request.header.set_cookie.color': 'blue' }],
670+
['token=eyJhbGc=.eyJzdWI=.SflKxw; Secure', { 'http.request.header.set_cookie.token': '[Filtered]' }],
671+
['auth_required; HttpOnly', { 'http.request.header.set_cookie.auth_required': '[Filtered]' }],
672+
['empty=; Secure', { 'http.request.header.set_cookie.empty': '' }],
673+
])('should parse and filter Set-Cookie header: %s', (setCookieValue, expected) => {
674+
const headers = { 'Set-Cookie': setCookieValue };
675+
const result = httpHeadersToSpanAttributes(headers);
676+
expect(result).toEqual(expected);
677+
});
678+
679+
it('only splits cookies once between key and value, even when more equals signs are present', () => {
680+
const headers = { Cookie: 'random-string=eyJhbGc=.eyJzdWI=.SflKxw' };
681+
const result = httpHeadersToSpanAttributes(headers);
682+
expect(result).toEqual({ 'http.request.header.cookie.random_string': 'eyJhbGc=.eyJzdWI=.SflKxw' });
683+
});
684+
685+
it.each([
686+
{ sendDefaultPii: false, description: 'sendDefaultPii is false (default)' },
687+
{ sendDefaultPii: true, description: 'sendDefaultPii is true' },
688+
])('does not include PII headers when $description', ({ sendDefaultPii }) => {
689+
const headers = {
690+
'Content-Type': 'application/json',
691+
'User-Agent': 'Mozilla/5.0',
692+
'x-user': 'my-personal-username',
693+
'X-Forwarded-For': '192.168.1.1',
694+
'X-Forwarded-Host': 'example.com',
695+
'X-Forwarded-Proto': 'https',
696+
};
697+
698+
const result = httpHeadersToSpanAttributes(headers, sendDefaultPii);
699+
700+
if (sendDefaultPii) {
701+
expect(result).toEqual({
702+
'http.request.header.content_type': 'application/json',
703+
'http.request.header.user_agent': 'Mozilla/5.0',
704+
'http.request.header.x_user': 'my-personal-username',
705+
'http.request.header.x_forwarded_for': '192.168.1.1',
706+
'http.request.header.x_forwarded_host': 'example.com',
707+
'http.request.header.x_forwarded_proto': 'https',
708+
});
709+
} else {
710+
expect(result).toEqual({
711+
'http.request.header.content_type': 'application/json',
712+
'http.request.header.user_agent': 'Mozilla/5.0',
713+
'http.request.header.x_user': '[Filtered]',
714+
'http.request.header.x_forwarded_for': '[Filtered]',
715+
'http.request.header.x_forwarded_host': '[Filtered]',
716+
'http.request.header.x_forwarded_proto': '[Filtered]',
717+
});
718+
}
719+
});
720+
634721
it('always filters comprehensive list of sensitive headers', () => {
635722
const headers = {
636723
'Content-Type': 'application/json',
@@ -649,8 +736,8 @@ describe('request utils', () => {
649736
'WWW-Authenticate': 'Basic',
650737
'Proxy-Authorization': 'Basic auth',
651738
'X-Access-Token': 'access',
652-
'X-CSRF-Token': 'csrf',
653-
'X-XSRF-Token': 'xsrf',
739+
'X-CSRF': 'csrf',
740+
'X-XSRF': 'xsrf',
654741
'X-Session-Token': 'session',
655742
'X-Password': 'password',
656743
'X-Private-Key': 'private',
@@ -671,17 +758,17 @@ describe('request utils', () => {
671758
'http.request.header.accept': 'application/json',
672759
'http.request.header.host': 'example.com',
673760
'http.request.header.authorization': '[Filtered]',
674-
'http.request.header.cookie': '[Filtered]',
675-
'http.request.header.set_cookie': '[Filtered]',
761+
'http.request.header.cookie.session': '[Filtered]',
762+
'http.request.header.set_cookie.session': '[Filtered]',
676763
'http.request.header.x_api_key': '[Filtered]',
677764
'http.request.header.x_auth_token': '[Filtered]',
678765
'http.request.header.x_secret': '[Filtered]',
679766
'http.request.header.x_secret_key': '[Filtered]',
680767
'http.request.header.www_authenticate': '[Filtered]',
681768
'http.request.header.proxy_authorization': '[Filtered]',
682769
'http.request.header.x_access_token': '[Filtered]',
683-
'http.request.header.x_csrf_token': '[Filtered]',
684-
'http.request.header.x_xsrf_token': '[Filtered]',
770+
'http.request.header.x_csrf': '[Filtered]',
771+
'http.request.header.x_xsrf': '[Filtered]',
685772
'http.request.header.x_session_token': '[Filtered]',
686773
'http.request.header.x_password': '[Filtered]',
687774
'http.request.header.x_private_key': '[Filtered]',

packages/nextjs/src/common/utils/addHeadersAsAttributes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Span, WebFetchHeaders } from '@sentry/core';
2-
import { httpHeadersToSpanAttributes, winterCGHeadersToDict } from '@sentry/core';
2+
import { getClient, httpHeadersToSpanAttributes, winterCGHeadersToDict } from '@sentry/core';
33

44
/**
55
* Extracts HTTP request headers as span attributes and optionally applies them to a span.
@@ -17,7 +17,7 @@ export function addHeadersAsAttributes(
1717
? winterCGHeadersToDict(headers as Headers)
1818
: headers;
1919

20-
const headerAttributes = httpHeadersToSpanAttributes(headersDict);
20+
const headerAttributes = httpHeadersToSpanAttributes(headersDict, getClient()?.getOptions().sendDefaultPii ?? false);
2121

2222
if (span) {
2323
span.setAttributes(headerAttributes);

packages/node-core/src/integrations/http/httpServerSpansIntegration.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,10 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions
157157
'http.flavor': httpVersion,
158158
'net.transport': httpVersion?.toUpperCase() === 'QUIC' ? 'ip_udp' : 'ip_tcp',
159159
...getRequestContentLengthAttribute(request),
160-
...httpHeadersToSpanAttributes(normalizedRequest.headers || {}),
160+
...httpHeadersToSpanAttributes(
161+
normalizedRequest.headers || {},
162+
client.getOptions().sendDefaultPii ?? false,
163+
),
161164
},
162165
});
163166

packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
captureException,
33
debug,
44
flushIfServerless,
5+
getClient,
56
httpHeadersToSpanAttributes,
67
SEMANTIC_ATTRIBUTE_SENTRY_OP,
78
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
@@ -172,7 +173,7 @@ function getSpanAttributes(
172173

173174
// Get headers from the Node.js request object
174175
const headers = event.node?.req?.headers || {};
175-
const headerAttributes = httpHeadersToSpanAttributes(headers);
176+
const headerAttributes = httpHeadersToSpanAttributes(headers, getClient()?.getOptions().sendDefaultPii ?? false);
176177

177178
// Merge header attributes with existing attributes
178179
Object.assign(attributes, headerAttributes);

packages/remix/src/server/instrumentServer.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,10 @@ function wrapRequestHandler<T extends ServerBuild | (() => ServerBuild | Promise
359359
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
360360
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
361361
method: request.method,
362-
...httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers)),
362+
...httpHeadersToSpanAttributes(
363+
winterCGHeadersToDict(request.headers),
364+
clientOptions.sendDefaultPii ?? false,
365+
),
363366
},
364367
},
365368
async span => {

0 commit comments

Comments
 (0)