diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index 64fde266a3f8..a12c25ff6045 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -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) { diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 73998e529349..83e7f5ff4967 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -3,6 +3,7 @@ import { captureException, continueTrace, defineIntegration, + getClient, httpHeadersToSpanAttributes, isURLObjectRelative, parseStringToURLObject, @@ -206,7 +207,10 @@ function wrapRequestHandler( routeName = route; } - Object.assign(attributes, httpHeadersToSpanAttributes(request.headers.toJSON())); + Object.assign( + attributes, + httpHeadersToSpanAttributes(request.headers.toJSON(), getClient()?.getOptions().sendDefaultPii ?? false), + ); isolationScope.setSDKProcessingMetadata({ normalizedRequest: { diff --git a/packages/cloudflare/src/request.ts b/packages/cloudflare/src/request.ts index 20706e8b9146..c404e57d01d8 100644 --- a/packages/cloudflare/src/request.ts +++ b/packages/cloudflare/src/request.ts @@ -3,6 +3,7 @@ import { captureException, continueTrace, flush, + getClient, getHttpSpanDetailsFromUrlObject, httpHeadersToSpanAttributes, parseStringToURLObject, @@ -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'; diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts index 1d3985dd8479..d328a16e05d9 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -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', + 'csrf', + '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. @@ -152,6 +160,7 @@ const SENSITIVE_HEADER_SNIPPETS = [ */ export function httpHeadersToSpanAttributes( headers: Record, + sendDefaultPii: boolean = false, ): Record { const spanAttributes: Record = {}; @@ -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'; + + if (isCookieHeader && typeof value === 'string' && value !== '') { + // 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(';'); + 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 { @@ -180,6 +202,47 @@ export function httpHeadersToSpanAttributes( return spanAttributes; } +function normalizeAttributeKey(key: string): string { + return key.replace(/-/g, '_'); +} + +function addSpanAttribute( + spanAttributes: Record, + 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 diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index 328aebf29209..c17c25802599 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -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', @@ -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', @@ -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', @@ -649,8 +736,8 @@ describe('request utils', () => { 'WWW-Authenticate': 'Basic', 'Proxy-Authorization': 'Basic auth', 'X-Access-Token': 'access', - 'X-CSRF-Token': 'csrf', - 'X-XSRF-Token': 'xsrf', + 'X-CSRF': 'csrf', + 'X-XSRF': 'xsrf', 'X-Session-Token': 'session', 'X-Password': 'password', 'X-Private-Key': 'private', @@ -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]', @@ -680,8 +767,8 @@ describe('request utils', () => { 'http.request.header.www_authenticate': '[Filtered]', 'http.request.header.proxy_authorization': '[Filtered]', 'http.request.header.x_access_token': '[Filtered]', - 'http.request.header.x_csrf_token': '[Filtered]', - 'http.request.header.x_xsrf_token': '[Filtered]', + 'http.request.header.x_csrf': '[Filtered]', + 'http.request.header.x_xsrf': '[Filtered]', 'http.request.header.x_session_token': '[Filtered]', 'http.request.header.x_password': '[Filtered]', 'http.request.header.x_private_key': '[Filtered]', diff --git a/packages/nextjs/src/common/utils/addHeadersAsAttributes.ts b/packages/nextjs/src/common/utils/addHeadersAsAttributes.ts index ff025fc3ecc7..8d4b0eca3724 100644 --- a/packages/nextjs/src/common/utils/addHeadersAsAttributes.ts +++ b/packages/nextjs/src/common/utils/addHeadersAsAttributes.ts @@ -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. @@ -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); diff --git a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts index 34741e95c912..7909482a5923 100644 --- a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts @@ -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, + ), }, }); diff --git a/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts b/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts index 84d80a7c6f80..b257d70b72d7 100644 --- a/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts +++ b/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts @@ -2,6 +2,7 @@ import { captureException, debug, flushIfServerless, + getClient, httpHeadersToSpanAttributes, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -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); diff --git a/packages/remix/src/server/instrumentServer.ts b/packages/remix/src/server/instrumentServer.ts index 273f72d809c1..d8864d254a99 100644 --- a/packages/remix/src/server/instrumentServer.ts +++ b/packages/remix/src/server/instrumentServer.ts @@ -359,7 +359,10 @@ function wrapRequestHandler 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 => { diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index 3d9963bd1056..26872a0f6f24 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -3,6 +3,7 @@ import { continueTrace, debug, flushIfServerless, + getClient, getCurrentScope, getDefaultIsolationScope, getIsolationScope, @@ -178,7 +179,10 @@ async function instrumentHandle( [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeName ? 'route' : 'url', 'sveltekit.tracing.original_name': originalName, - ...httpHeadersToSpanAttributes(winterCGHeadersToDict(event.request.headers)), + ...httpHeadersToSpanAttributes( + winterCGHeadersToDict(event.request.headers), + getClient()?.getOptions().sendDefaultPii ?? false, + ), }); } @@ -204,7 +208,10 @@ async function instrumentHandle( [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeId ? 'route' : 'url', 'http.method': event.request.method, - ...httpHeadersToSpanAttributes(winterCGHeadersToDict(event.request.headers)), + ...httpHeadersToSpanAttributes( + winterCGHeadersToDict(event.request.headers), + getClient()?.getOptions().sendDefaultPii ?? false, + ), }, name: routeName, },