Skip to content

Commit 22cb73e

Browse files
committed
feat(core): Parse individual cookies from cookie header
1 parent ebb8eed commit 22cb73e

10 files changed

Lines changed: 169 additions & 25 deletions

File tree

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,
@@ -66,7 +67,13 @@ export function wrapRequestHandler(
6667
attributes['user_agent.original'] = userAgentHeader;
6768
}
6869

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

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

packages/core/src/utils/request.ts

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getClient } from '../currentScopes';
12
import type { PolymorphicRequest } from '../types-hoist/polymorphics';
23
import type { RequestEventData } from '../types-hoist/request';
34
import type { WebFetchHeaders, WebFetchRequest } from '../types-hoist/webfetchapi';
@@ -128,21 +129,29 @@ function getAbsoluteUrl({
128129
return undefined;
129130
}
130131

131-
// "-user" because otherwise it would match "user-agent"
132132
const SENSITIVE_HEADER_SNIPPETS = [
133133
'auth',
134134
'token',
135135
'secret',
136-
'cookie',
137-
'-user',
136+
'session', // for the user_session cookie
138137
'password',
138+
'passwd',
139+
'pwd',
139140
'key',
140141
'jwt',
141142
'bearer',
142143
'sso',
143144
'saml',
145+
'crsf',
146+
'xsrf',
147+
'credentials',
148+
// Always treat cookie headers as sensitive in case individual key-value cookie pairs cannot properly be extracted
149+
'set-cookie',
150+
'cookie',
144151
];
145152

153+
const PII_HEADER_SNIPPETS = ['x-forwarded-', '-user'];
154+
146155
/**
147156
* Converts incoming HTTP request headers to OpenTelemetry span attributes following semantic conventions.
148157
* Header names are converted to the format: http.request.header.<key>
@@ -152,6 +161,7 @@ const SENSITIVE_HEADER_SNIPPETS = [
152161
*/
153162
export function httpHeadersToSpanAttributes(
154163
headers: Record<string, string | string[] | undefined>,
164+
sendDefaultPii: boolean = false,
155165
): Record<string, string> {
156166
const spanAttributes: Record<string, string> = {};
157167

@@ -161,16 +171,22 @@ export function httpHeadersToSpanAttributes(
161171
return;
162172
}
163173

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, '_')}`;
174+
const lowerCasedHeaderKey = key.toLowerCase();
175+
const isCookieHeader = lowerCasedHeaderKey === 'cookie' || lowerCasedHeaderKey === 'set-cookie';
167176

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;
177+
if (isCookieHeader && typeof value === 'string' && value !== '') {
178+
const cookies = value.split('; ');
179+
180+
for (const cookie of cookies) {
181+
const [cookieKey, cookieValue] = cookie.split('=');
182+
const lowerCasedCookieKey = String(cookieKey).toLowerCase();
183+
const normalizedKey = `http.request.header.${normalizeAttributeKey(lowerCasedHeaderKey)}.${normalizeAttributeKey(lowerCasedCookieKey)}`;
184+
185+
spanAttributes[normalizedKey] = handleHttpHeader(lowerCasedCookieKey, cookieValue, sendDefaultPii);
186+
}
187+
} else {
188+
const normalizedKey = `http.request.header.${normalizeAttributeKey(lowerCasedHeaderKey)}`;
189+
spanAttributes[normalizedKey] = handleHttpHeader(lowerCasedHeaderKey, value, sendDefaultPii);
174190
}
175191
});
176192
} catch {
@@ -180,6 +196,26 @@ export function httpHeadersToSpanAttributes(
180196
return spanAttributes;
181197
}
182198

199+
function normalizeAttributeKey(key: string): string {
200+
return key.replace(/-/g, '_');
201+
}
202+
203+
function handleHttpHeader(lowerCasedKey: string, value: string | string[] | undefined, sendPii: boolean): string {
204+
const isSensitive = sendPii
205+
? SENSITIVE_HEADER_SNIPPETS.some(snippet => lowerCasedKey.includes(snippet))
206+
: [...PII_HEADER_SNIPPETS, ...SENSITIVE_HEADER_SNIPPETS].some(snippet => lowerCasedKey.includes(snippet));
207+
208+
if (isSensitive) {
209+
return '[Filtered]';
210+
} else if (Array.isArray(value)) {
211+
return value.map(v => (v != null ? String(v) : v)).join(';');
212+
} else if (typeof value === 'string') {
213+
return value;
214+
}
215+
216+
return ''; // Fallback for unexpected types
217+
}
218+
183219
/** Extract the query params from an URL. */
184220
export function extractQueryParamsFromUrl(url: string): string | undefined {
185221
// url is path and query string

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

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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,95 @@ 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('attaches and filters sensitive a set-cookie header', () => {
664+
const headers1 = { 'Set-Cookie': 'user_session=def456' };
665+
const result1 = httpHeadersToSpanAttributes(headers1);
666+
expect(result1).toEqual({ 'http.request.header.set_cookie.user_session': '[Filtered]' });
667+
668+
const headers2 = { 'Set-Cookie': 'preferred-color-mode=light' };
669+
const result2 = httpHeadersToSpanAttributes(headers2);
670+
expect(result2).toEqual({ 'http.request.header.set_cookie.preferred_color_mode': 'light' });
671+
672+
const headers3 = { 'Set-Cookie': 'lang=en' };
673+
const result3 = httpHeadersToSpanAttributes(headers3);
674+
expect(result3).toEqual({ 'http.request.header.set_cookie.lang': 'en' });
675+
676+
const headers4 = { 'Set-Cookie': 'timezone=UTC' };
677+
const result4 = httpHeadersToSpanAttributes(headers4);
678+
expect(result4).toEqual({ 'http.request.header.set_cookie.timezone': 'UTC' });
679+
});
680+
681+
it.each([
682+
{ sendDefaultPii: false, description: 'sendDefaultPii is false (default)' },
683+
{ sendDefaultPii: true, description: 'sendDefaultPii is true' },
684+
])('does not include PII headers when $description', ({ sendDefaultPii }) => {
685+
const headers = {
686+
'Content-Type': 'application/json',
687+
'User-Agent': 'Mozilla/5.0',
688+
'x-user': 'my-personal-username',
689+
'X-Forwarded-For': '192.168.1.1',
690+
'X-Forwarded-Host': 'example.com',
691+
'X-Forwarded-Proto': 'https',
692+
};
693+
694+
const result = httpHeadersToSpanAttributes(headers, sendDefaultPii);
695+
696+
if (sendDefaultPii) {
697+
expect(result).toEqual({
698+
'http.request.header.content_type': 'application/json',
699+
'http.request.header.user_agent': 'Mozilla/5.0',
700+
'http.request.header.x_user': 'my-personal-username',
701+
'http.request.header.x_forwarded_for': '192.168.1.1',
702+
'http.request.header.x_forwarded_host': 'example.com',
703+
'http.request.header.x_forwarded_proto': 'https',
704+
});
705+
} else {
706+
expect(result).toEqual({
707+
'http.request.header.content_type': 'application/json',
708+
'http.request.header.user_agent': 'Mozilla/5.0',
709+
'http.request.header.x_user': '[Filtered]',
710+
'http.request.header.x_forwarded_for': '[Filtered]',
711+
'http.request.header.x_forwarded_host': '[Filtered]',
712+
'http.request.header.x_forwarded_proto': '[Filtered]',
713+
});
714+
}
715+
});
716+
634717
it('always filters comprehensive list of sensitive headers', () => {
635718
const headers = {
636719
'Content-Type': 'application/json',
@@ -671,8 +754,8 @@ describe('request utils', () => {
671754
'http.request.header.accept': 'application/json',
672755
'http.request.header.host': 'example.com',
673756
'http.request.header.authorization': '[Filtered]',
674-
'http.request.header.cookie': '[Filtered]',
675-
'http.request.header.set_cookie': '[Filtered]',
757+
'http.request.header.cookie.session': '[Filtered]',
758+
'http.request.header.set_cookie.session': '[Filtered]',
676759
'http.request.header.x_api_key': '[Filtered]',
677760
'http.request.header.x_auth_token': '[Filtered]',
678761
'http.request.header.x_secret': '[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
@@ -3,6 +3,7 @@ import {
33
captureException,
44
debug,
55
flushIfServerless,
6+
getClient,
67
httpHeadersToSpanAttributes,
78
SEMANTIC_ATTRIBUTE_SENTRY_OP,
89
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
@@ -310,7 +310,10 @@ function wrapRequestHandler<T extends ServerBuild | (() => ServerBuild | Promise
310310
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
311311
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
312312
method: request.method,
313-
...httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers)),
313+
...httpHeadersToSpanAttributes(
314+
winterCGHeadersToDict(request.headers),
315+
clientOptions.sendDefaultPii ?? false,
316+
),
314317
},
315318
},
316319
async span => {

packages/sveltekit/src/server-common/handle.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
winterCGRequestToRequestData,
2020
withIsolationScope,
2121
} from '@sentry/core';
22+
import { getClient } from '@sentry/svelte';
2223
import type { Handle, ResolveOptions } from '@sveltejs/kit';
2324
import { DEBUG_BUILD } from '../common/debug-build';
2425
import { getTracePropagationData, sendErrorToSentry } from './utils';
@@ -204,7 +205,10 @@ async function instrumentHandle(
204205
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit',
205206
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeId ? 'route' : 'url',
206207
'http.method': event.request.method,
207-
...httpHeadersToSpanAttributes(winterCGHeadersToDict(event.request.headers)),
208+
...httpHeadersToSpanAttributes(
209+
winterCGHeadersToDict(event.request.headers),
210+
getClient()?.getOptions().sendDefaultPii ?? false,
211+
),
208212
},
209213
name: routeName,
210214
},

0 commit comments

Comments
 (0)