Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/astro/src/server/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,10 @@ async function instrumentRequestStartHttpServerSpan(
// This is here for backwards compatibility, we used to set this here before
method,
url: stripUrlQueryAndFragment(ctx.url.href),
...httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers)),
...httpHeadersToSpanAttributes(
winterCGHeadersToDict(request.headers),
getClient()?.getOptions().sendDefaultPii ?? false,
),
};

if (parametrizedRoute) {
Expand Down
6 changes: 5 additions & 1 deletion packages/bun/src/integrations/bunserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
captureException,
continueTrace,
defineIntegration,
getClient,
httpHeadersToSpanAttributes,
isURLObjectRelative,
parseStringToURLObject,
Expand Down Expand Up @@ -206,7 +207,10 @@ function wrapRequestHandler<T extends RouteHandler = RouteHandler>(
routeName = route;
}

Object.assign(attributes, httpHeadersToSpanAttributes(request.headers.toJSON()));
Object.assign(
attributes,
httpHeadersToSpanAttributes(request.headers.toJSON(), getClient()?.getOptions().sendDefaultPii ?? false),
);

isolationScope.setSDKProcessingMetadata({
normalizedRequest: {
Expand Down
9 changes: 8 additions & 1 deletion packages/cloudflare/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
captureException,
continueTrace,
flush,
getClient,
getHttpSpanDetailsFromUrlObject,
httpHeadersToSpanAttributes,
parseStringToURLObject,
Expand Down Expand Up @@ -67,7 +68,13 @@ export function wrapRequestHandler(
attributes['user_agent.original'] = userAgentHeader;
}

Object.assign(attributes, httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers)));
Object.assign(
attributes,
httpHeadersToSpanAttributes(
winterCGHeadersToDict(request.headers),
getClient()?.getOptions().sendDefaultPii ?? false,
),
);

attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server';

Expand Down
87 changes: 75 additions & 12 deletions packages/core/src/utils/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,21 +128,29 @@ function getAbsoluteUrl({
return undefined;
}

// "-user" because otherwise it would match "user-agent"
const SENSITIVE_HEADER_SNIPPETS = [
'auth',
'token',
'secret',
'cookie',
'-user',
'session', // for the user_session cookie
'password',
'passwd',
'pwd',
'key',
'jwt',
'bearer',
'sso',
'saml',
'crsf',
'xsrf',
'credentials',
// Always treat cookie headers as sensitive in case individual key-value cookie pairs cannot properly be extracted
'set-cookie',
'cookie',
];

const PII_HEADER_SNIPPETS = ['x-forwarded-', '-user'];

/**
* Converts incoming HTTP request headers to OpenTelemetry span attributes following semantic conventions.
* Header names are converted to the format: http.request.header.<key>
Expand All @@ -152,6 +160,7 @@ const SENSITIVE_HEADER_SNIPPETS = [
*/
export function httpHeadersToSpanAttributes(
headers: Record<string, string | string[] | undefined>,
sendDefaultPii: boolean = false,
): Record<string, string> {
const spanAttributes: Record<string, string> = {};

Expand All @@ -161,16 +170,29 @@ export function httpHeadersToSpanAttributes(
return;
}

const lowerCasedKey = key.toLowerCase();
const isSensitive = SENSITIVE_HEADER_SNIPPETS.some(snippet => lowerCasedKey.includes(snippet));
const normalizedKey = `http.request.header.${lowerCasedKey.replace(/-/g, '_')}`;
const lowerCasedHeaderKey = key.toLowerCase();
const isCookieHeader = lowerCasedHeaderKey === 'cookie' || lowerCasedHeaderKey === 'set-cookie';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: probably saves us a few bytes:

Suggested change
const isCookieHeader = lowerCasedHeaderKey === 'cookie' || lowerCasedHeaderKey === 'set-cookie';
const isCookieHeader = /^(set-)cookie$?/.test(lowerCasedHeaderKey)


if (isCookieHeader && typeof value === 'string' && value !== '') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: do we handle arrays of cookie headers? (or is this not relevant for cookie/set-cookie?)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the sake of simplicity (as cookies are one string), an array would just be parsed as ...header.cookie: [Filtered]. There is also a test that checks that a cookie attribute is always filtered.

// Set-Cookie: single cookie with attributes ("name=value; HttpOnly; Secure")
// Cookie: multiple cookies separated by "; " ("cookie1=value1; cookie2=value2")
const isSetCookie = lowerCasedHeaderKey === 'set-cookie';
const semicolonIndex = value.indexOf(';');
Comment on lines +179 to +180

This comment was marked as outdated.

const cookieString = isSetCookie && semicolonIndex !== -1 ? value.substring(0, semicolonIndex) : value;
const cookies = isSetCookie ? [cookieString] : cookieString.split('; ');

for (const cookie of cookies) {
// Split only at the first '=' to preserve '=' characters in cookie values
const equalSignIndex = cookie.indexOf('=');
const cookieKey = equalSignIndex !== -1 ? cookie.substring(0, equalSignIndex) : cookie;
const cookieValue = equalSignIndex !== -1 ? cookie.substring(equalSignIndex + 1) : '';

if (isSensitive) {
spanAttributes[normalizedKey] = '[Filtered]';
} else if (Array.isArray(value)) {
spanAttributes[normalizedKey] = value.map(v => (v != null ? String(v) : v)).join(';');
} else if (typeof value === 'string') {
spanAttributes[normalizedKey] = value;
const lowerCasedCookieKey = cookieKey.toLowerCase();

addSpanAttribute(spanAttributes, lowerCasedHeaderKey, lowerCasedCookieKey, cookieValue, sendDefaultPii);
}
} else {
addSpanAttribute(spanAttributes, lowerCasedHeaderKey, '', value, sendDefaultPii);
}
});
} catch {
Expand All @@ -180,6 +202,47 @@ export function httpHeadersToSpanAttributes(
return spanAttributes;
}

This comment was marked as outdated.


function normalizeAttributeKey(key: string): string {
return key.replace(/-/g, '_');
}

function addSpanAttribute(
spanAttributes: Record<string, string>,
headerKey: string,
cookieKey: string,
value: string | string[] | undefined,
sendPii: boolean,
): void {
const normalizedKey = cookieKey
? `http.request.header.${normalizeAttributeKey(headerKey)}.${normalizeAttributeKey(cookieKey)}`
: `http.request.header.${normalizeAttributeKey(headerKey)}`;

const headerValue = handleHttpHeader(cookieKey || headerKey, value, sendPii);
if (headerValue !== undefined) {
spanAttributes[normalizedKey] = headerValue;
}
}

function handleHttpHeader(
lowerCasedKey: string,
value: string | string[] | undefined,
sendPii: boolean,
): string | undefined {
const isSensitive = sendPii
? SENSITIVE_HEADER_SNIPPETS.some(snippet => lowerCasedKey.includes(snippet))
: [...PII_HEADER_SNIPPETS, ...SENSITIVE_HEADER_SNIPPETS].some(snippet => lowerCasedKey.includes(snippet));

if (isSensitive) {
return '[Filtered]';
} else if (Array.isArray(value)) {
return value.map(v => (v != null ? String(v) : v)).join(';');
} else if (typeof value === 'string') {
return value;
}

return undefined;
}

/** Extract the query params from an URL. */
export function extractQueryParamsFromUrl(url: string): string | undefined {
// url is path and query string
Expand Down
97 changes: 92 additions & 5 deletions packages/core/test/lib/utils/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ describe('request utils', () => {
'X-Forwarded-For': '192.168.1.1',
};

const result = httpHeadersToSpanAttributes(headers);
const result = httpHeadersToSpanAttributes(headers, true);

expect(result).toEqual({
'http.request.header.host': 'example.com',
Expand Down Expand Up @@ -612,7 +612,7 @@ describe('request utils', () => {
});
});

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

expect(result).toEqual({
'http.request.header.content_type': 'application/json',
'http.request.header.cookie': '[Filtered]',
'http.request.header.cookie.session': '[Filtered]',
'http.request.header.x_api_key': '[Filtered]',
'http.request.header.authorization': '[Filtered]',
});
});

it('attaches and filters sensitive cookie headers', () => {
const headers = {
Cookie:
'session=abc123; tracking=enabled; cookie-authentication-key-without-value; theme=dark; lang=en; user_session=xyz789; pref=1',
};

const result = httpHeadersToSpanAttributes(headers);

expect(result).toEqual({
'http.request.header.cookie.session': '[Filtered]',
'http.request.header.cookie.tracking': 'enabled',
'http.request.header.cookie.theme': 'dark',
'http.request.header.cookie.lang': 'en',
'http.request.header.cookie.user_session': '[Filtered]',
'http.request.header.cookie.cookie_authentication_key_without_value': '[Filtered]',
'http.request.header.cookie.pref': '1',
});
});

it('adds a filtered cookie header when cookie header is present, but has no valid key=value pairs', () => {
const headers1 = { Cookie: ['key', 'val'] };
const result1 = httpHeadersToSpanAttributes(headers1);
expect(result1).toEqual({ 'http.request.header.cookie': '[Filtered]' });

const headers3 = { Cookie: '' };
const result3 = httpHeadersToSpanAttributes(headers3);
expect(result3).toEqual({ 'http.request.header.cookie': '[Filtered]' });
});

it.each([
['preferred-color-mode=light', { 'http.request.header.set_cookie.preferred_color_mode': 'light' }],
['theme=dark; HttpOnly', { 'http.request.header.set_cookie.theme': 'dark' }],
['session=abc123; Domain=example.com; HttpOnly', { 'http.request.header.set_cookie.session': '[Filtered]' }],
['lang=en; Expires=Wed, 21 Oct 2025 07:28:00 GMT', { 'http.request.header.set_cookie.lang': 'en' }],
['pref=1; Max-Age=3600', { 'http.request.header.set_cookie.pref': '1' }],
['color=blue; Path=/dashboard', { 'http.request.header.set_cookie.color': 'blue' }],
['token=eyJhbGc=.eyJzdWI=.SflKxw; Secure', { 'http.request.header.set_cookie.token': '[Filtered]' }],
['auth_required; HttpOnly', { 'http.request.header.set_cookie.auth_required': '[Filtered]' }],
['empty=; Secure', { 'http.request.header.set_cookie.empty': '' }],
])('should parse and filter Set-Cookie header: %s', (setCookieValue, expected) => {
const headers = { 'Set-Cookie': setCookieValue };
const result = httpHeadersToSpanAttributes(headers);
expect(result).toEqual(expected);
});

it('only splits cookies once between key and value, even when more equals signs are present', () => {
const headers = { Cookie: 'random-string=eyJhbGc=.eyJzdWI=.SflKxw' };
const result = httpHeadersToSpanAttributes(headers);
expect(result).toEqual({ 'http.request.header.cookie.random_string': 'eyJhbGc=.eyJzdWI=.SflKxw' });
});

it.each([
{ sendDefaultPii: false, description: 'sendDefaultPii is false (default)' },
{ sendDefaultPii: true, description: 'sendDefaultPii is true' },
])('does not include PII headers when $description', ({ sendDefaultPii }) => {
const headers = {
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0',
'x-user': 'my-personal-username',
'X-Forwarded-For': '192.168.1.1',
'X-Forwarded-Host': 'example.com',
'X-Forwarded-Proto': 'https',
};

const result = httpHeadersToSpanAttributes(headers, sendDefaultPii);

if (sendDefaultPii) {
expect(result).toEqual({
'http.request.header.content_type': 'application/json',
'http.request.header.user_agent': 'Mozilla/5.0',
'http.request.header.x_user': 'my-personal-username',
'http.request.header.x_forwarded_for': '192.168.1.1',
'http.request.header.x_forwarded_host': 'example.com',
'http.request.header.x_forwarded_proto': 'https',
});
} else {
expect(result).toEqual({
'http.request.header.content_type': 'application/json',
'http.request.header.user_agent': 'Mozilla/5.0',
'http.request.header.x_user': '[Filtered]',
'http.request.header.x_forwarded_for': '[Filtered]',
'http.request.header.x_forwarded_host': '[Filtered]',
'http.request.header.x_forwarded_proto': '[Filtered]',
});
}
});

it('always filters comprehensive list of sensitive headers', () => {
const headers = {
'Content-Type': 'application/json',
Expand Down Expand Up @@ -671,8 +758,8 @@ describe('request utils', () => {
'http.request.header.accept': 'application/json',
'http.request.header.host': 'example.com',
'http.request.header.authorization': '[Filtered]',
'http.request.header.cookie': '[Filtered]',
'http.request.header.set_cookie': '[Filtered]',
'http.request.header.cookie.session': '[Filtered]',
'http.request.header.set_cookie.session': '[Filtered]',
'http.request.header.x_api_key': '[Filtered]',
'http.request.header.x_auth_token': '[Filtered]',
'http.request.header.x_secret': '[Filtered]',
Expand Down
4 changes: 2 additions & 2 deletions packages/nextjs/src/common/utils/addHeadersAsAttributes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Span, WebFetchHeaders } from '@sentry/core';
import { httpHeadersToSpanAttributes, winterCGHeadersToDict } from '@sentry/core';
import { getClient, httpHeadersToSpanAttributes, winterCGHeadersToDict } from '@sentry/core';

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

const headerAttributes = httpHeadersToSpanAttributes(headersDict);
const headerAttributes = httpHeadersToSpanAttributes(headersDict, getClient()?.getOptions().sendDefaultPii ?? false);

if (span) {
span.setAttributes(headerAttributes);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,10 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions
'http.flavor': httpVersion,
'net.transport': httpVersion?.toUpperCase() === 'QUIC' ? 'ip_udp' : 'ip_tcp',
...getRequestContentLengthAttribute(request),
...httpHeadersToSpanAttributes(normalizedRequest.headers || {}),
...httpHeadersToSpanAttributes(
normalizedRequest.headers || {},
client.getOptions().sendDefaultPii ?? false,
),
},
});

Expand Down
3 changes: 2 additions & 1 deletion packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
captureException,
debug,
flushIfServerless,
getClient,
httpHeadersToSpanAttributes,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
Expand Down Expand Up @@ -172,7 +173,7 @@ function getSpanAttributes(

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

// Merge header attributes with existing attributes
Object.assign(attributes, headerAttributes);
Expand Down
5 changes: 4 additions & 1 deletion packages/remix/src/server/instrumentServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,10 @@ function wrapRequestHandler<T extends ServerBuild | (() => ServerBuild | Promise
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
method: request.method,
...httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers)),
...httpHeadersToSpanAttributes(
winterCGHeadersToDict(request.headers),
clientOptions.sendDefaultPii ?? false,
),
},
},
async span => {
Expand Down
Loading
Loading