From 1c3b17e27f721204fed6f8c34b5cb58fec659ed4 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Tue, 24 Feb 2026 16:40:21 +0100 Subject: [PATCH] feat: first implementation of http headers capture --- packages/core/src/DdSdkReactNative.tsx | 12 +- .../src/__tests__/DdSdkReactNative.test.tsx | 3 +- .../DdSdkReactNativeConfiguration.test.ts | 3 + .../__tests__/FileBasedConfiguration.test.ts | 3 + .../async/AutoInstrumentationConfiguration.ts | 6 +- .../src/config/features/RumConfiguration.ts | 9 + .../config/features/RumConfiguration.type.ts | 113 +++ packages/core/src/index.tsx | 7 + .../DdRumResourceTracking.tsx | 12 +- .../__tests__/__utils__/XMLHttpRequestMock.ts | 2 +- .../distributedTracing/firstPartyHosts.ts | 2 +- .../resourceTracking/headerCapture/README.md | 142 ++++ .../__tests__/captureHeaders.test.ts | 775 ++++++++++++++++++ .../__tests__/compileHeaderConfig.test.ts | 430 ++++++++++ .../enforceSizeLimits.integration.test.ts | 183 +++++ .../__tests__/enforceSizeLimits.test.ts | 226 +++++ .../__tests__/isHeaderAllowed.test.ts | 35 + .../__tests__/parseResponseHeaders.test.ts | 105 +++ .../sensitiveHeaderBlocklist.test.ts | 109 +++ .../__tests__/tracingHeaderExclusion.test.ts | 58 ++ .../headerCapture/captureHeaders.ts | 328 ++++++++ .../headerCapture/compileHeaderConfig.ts | 300 +++++++ .../headerCapture/enforceSizeLimits.ts | 131 +++ .../headerCapture/isHeaderAllowed.ts | 29 + .../headerCapture/parseResponseHeaders.ts | 57 ++ .../headerCapture/sensitiveHeaderBlocklist.ts | 31 + .../headerCapture/tracingHeaderExclusion.ts | 59 ++ .../resourceTracking/headerCapture/types.ts | 33 + .../DatadogRumResource/ResourceReporter.ts | 18 +- .../__tests__/ResourceReporter.test.ts | 120 +++ .../requestProxy/XHRProxy/XHRProxy.ts | 64 +- .../XHRProxy/__tests__/XHRProxy.test.ts | 552 ++++++++++++- .../requestProxy/interfaces/RequestProxy.ts | 2 + .../requestProxy/interfaces/RumResource.ts | 2 + .../__tests__/initialization.test.tsx | 3 +- 35 files changed, 3920 insertions(+), 44 deletions(-) create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.integration.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/isHeaderAllowed.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/parseResponseHeaders.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/sensitiveHeaderBlocklist.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/tracingHeaderExclusion.test.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/isHeaderAllowed.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/parseResponseHeaders.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/sensitiveHeaderBlocklist.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/tracingHeaderExclusion.ts create mode 100644 packages/core/src/rum/instrumentation/resourceTracking/headerCapture/types.ts diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index bb0669a24..2a15ea715 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -500,6 +500,8 @@ export class DdSdkReactNative { const resourceTraceSampleRate = configuration.rumConfiguration?.resourceTraceSampleRate || RUM_DEFAULTS.resourceTraceSampleRate; + const headerCaptureRules = + configuration.rumConfiguration?.headerCaptureRules; const logEventMapper = configuration.logsConfiguration?.logEventMapper; const errorEventMapper = configuration.rumConfiguration?.errorEventMapper; @@ -535,10 +537,18 @@ export class DdSdkReactNative { }); } + if (!trackResources && headerCaptureRules !== undefined) { + InternalLog.log( + 'headerCaptureRules is set but trackResources is false. Header capture will be disabled.', + SdkVerbosity.WARN + ); + } + if (trackResources) { DdRumResourceTracking.startTracking({ resourceTraceSampleRate, - firstPartyHosts + firstPartyHosts, + headerCaptureRules }); } diff --git a/packages/core/src/__tests__/DdSdkReactNative.test.tsx b/packages/core/src/__tests__/DdSdkReactNative.test.tsx index d9abeafe2..b8a0b60db 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -676,7 +676,8 @@ describe('DdSdkReactNative', () => { match: 'something.fr', propagatorTypes: ['datadog'] } - ] + ], + headerCaptureRules: undefined }); }); diff --git a/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts b/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts index 207d9abdd..ed18fce79 100644 --- a/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts +++ b/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts @@ -61,6 +61,7 @@ describe('DdSdkReactNativeConfiguration', () => { "customEndpoint": undefined, "errorEventMapper": null, "firstPartyHosts": [], + "headerCaptureRules": undefined, "initialResourceThreshold": undefined, "longTaskThresholdMs": 0, "nativeCrashReportEnabled": false, @@ -205,6 +206,7 @@ describe('DdSdkReactNativeConfiguration', () => { ], }, ], + "headerCaptureRules": undefined, "initialResourceThreshold": 0.123, "longTaskThresholdMs": 567, "nativeCrashReportEnabled": true, @@ -308,6 +310,7 @@ describe('DdSdkReactNativeConfiguration', () => { "customEndpoint": undefined, "errorEventMapper": null, "firstPartyHosts": [], + "headerCaptureRules": undefined, "initialResourceThreshold": 0, "longTaskThresholdMs": false, "nativeCrashReportEnabled": false, diff --git a/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts b/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts index 5b062447f..3bc032789 100644 --- a/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts +++ b/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts @@ -55,6 +55,7 @@ describe('FileBasedConfiguration', () => { ], }, ], + "headerCaptureRules": undefined, "initialResourceThreshold": 456, "longTaskThresholdMs": 44, "nativeCrashReportEnabled": true, @@ -167,6 +168,7 @@ describe('FileBasedConfiguration', () => { ], }, ], + "headerCaptureRules": undefined, "initialResourceThreshold": undefined, "longTaskThresholdMs": 44, "nativeCrashReportEnabled": false, @@ -231,6 +233,7 @@ describe('FileBasedConfiguration', () => { "customEndpoint": undefined, "errorEventMapper": null, "firstPartyHosts": [], + "headerCaptureRules": undefined, "initialResourceThreshold": undefined, "longTaskThresholdMs": 0, "nativeCrashReportEnabled": false, diff --git a/packages/core/src/config/async/AutoInstrumentationConfiguration.ts b/packages/core/src/config/async/AutoInstrumentationConfiguration.ts index 199aaa4eb..c80fa0d32 100644 --- a/packages/core/src/config/async/AutoInstrumentationConfiguration.ts +++ b/packages/core/src/config/async/AutoInstrumentationConfiguration.ts @@ -9,6 +9,7 @@ import type { ResourceEventMapper } from '../../rum/eventMappers/resourceEventMa import type { FirstPartyHost } from '../../rum/types'; import type { LogEventMapper } from '../../types'; import { LOGS_DEFAULTS } from '../features/LogsConfiguration'; +import type { HeaderCaptureRule } from '../features/RumConfiguration.type'; import { RUM_DEFAULTS } from '../features/RumConfiguration'; import type { TraceConfiguration } from '../features/TraceConfiguration'; @@ -24,6 +25,7 @@ export type AutoInstrumentationConfiguration = { readonly useAccessibilityLabel?: boolean; readonly actionNameAttribute?: string; readonly resourceTraceSampleRate?: number; + readonly headerCaptureRules?: 'defaults' | HeaderCaptureRule[]; readonly nativeCrashReportEnabled?: boolean; readonly nativeLongTaskThresholdMs?: number; readonly nativeViewTracking?: boolean; @@ -55,6 +57,7 @@ export type AutoInstrumentationParameters = { readonly errorEventMapper: ErrorEventMapper | null; readonly resourceEventMapper: ResourceEventMapper | null; readonly firstPartyHosts: FirstPartyHost[]; + readonly headerCaptureRules?: 'defaults' | HeaderCaptureRule[]; }; readonly logsConfiguration?: { readonly logEventMapper: LogEventMapper | null; @@ -114,7 +117,8 @@ export const addDefaultValuesToAutoInstrumentationConfiguration = ( RUM_DEFAULTS.nativeViewTracking, firstPartyHosts: features.rumConfiguration.firstPartyHosts || - RUM_DEFAULTS.getFirstPartyHosts() + RUM_DEFAULTS.getFirstPartyHosts(), + headerCaptureRules: features.rumConfiguration.headerCaptureRules }, logsConfiguration: { logEventMapper: diff --git a/packages/core/src/config/features/RumConfiguration.ts b/packages/core/src/config/features/RumConfiguration.ts index 3ab70409c..2d1c7508d 100644 --- a/packages/core/src/config/features/RumConfiguration.ts +++ b/packages/core/src/config/features/RumConfiguration.ts @@ -10,6 +10,7 @@ import type { FirstPartyHost } from '../../rum/types'; import { VitalsUpdateFrequency } from '../types'; import type { + HeaderCaptureRule, RumConfigurationOptions, RumConfigurationType } from './RumConfiguration.type'; @@ -37,6 +38,10 @@ const DEFAULTS = { trackInteractions: false, trackMemoryWarnings: true, trackNonFatalAnrs: undefined, + headerCaptureRules: undefined as + | 'defaults' + | HeaderCaptureRule[] + | undefined, trackResources: false, trackWatchdogTerminations: false, useAccessibilityLabel: true, @@ -112,6 +117,10 @@ export class RumConfiguration implements RumConfigurationType { // Track non-fatal ANRs enabled public trackNonFatalAnrs?: boolean = DEFAULTS.trackNonFatalAnrs; + // Header Capture Rules + public headerCaptureRules: 'defaults' | HeaderCaptureRule[] | undefined = + DEFAULTS.headerCaptureRules; + // Track Watchdog Terminations enabled public trackWatchdogTerminations: boolean = DEFAULTS.trackWatchdogTerminations; diff --git a/packages/core/src/config/features/RumConfiguration.type.ts b/packages/core/src/config/features/RumConfiguration.type.ts index 0a2094ece..82a1606ac 100644 --- a/packages/core/src/config/features/RumConfiguration.type.ts +++ b/packages/core/src/config/features/RumConfiguration.type.ts @@ -37,6 +37,106 @@ export interface RumConfigurationRequired { trackErrors?: boolean; } +/** + * Captures a predefined set of caching and content headers from both + * request and response. + * + * Default headers captured: + * - **Response:** cache-control, etag, age, expires, content-type, + * content-encoding, content-length, vary, server-timing, x-cache + * - **Request:** cache-control, content-type + * + * Optionally scoped to specific URLs via `forURLs`. + */ +export type DefaultsRule = { + type: 'defaults'; + /** + * URL patterns to scope this rule to. Supports hostname-only + * ('api.example.com') or hostname+path prefix ('api.example.com/v2'). + * Omit to match all URLs. + */ + forURLs?: string[]; +}; + +/** + * Captures the specified headers from both request and response. + * + * Use this when the same header names should be captured regardless + * of direction (e.g. 'content-type', 'authorization'). + * Optionally scoped to specific URLs via `forURLs`. + */ +export type MatchHeadersRule = { + type: 'matchHeaders'; + /** + * Header names to capture from both request and response. + * Preserved as-is; compared case-insensitively at capture time. + */ + headers: string[]; + /** + * URL patterns to scope this rule to. Supports hostname-only + * ('api.example.com') or hostname+path prefix ('api.example.com/v2'). + * Omit to match all URLs. + */ + forURLs?: string[]; +}; + +/** + * Captures the specified headers from requests only. + * + * Use this when you need to capture request-specific headers + * (e.g. 'authorization', 'x-api-key') without capturing response headers. + * Optionally scoped to specific URLs via `forURLs`. + */ +export type MatchRequestHeadersRule = { + type: 'matchRequestHeaders'; + /** + * Request header names to capture. + * Preserved as-is; compared case-insensitively at capture time. + */ + headers: string[]; + /** + * URL patterns to scope this rule to. Supports hostname-only + * ('api.example.com') or hostname+path prefix ('api.example.com/v2'). + * Omit to match all URLs. + */ + forURLs?: string[]; +}; + +/** + * Captures the specified headers from responses only. + * + * Use this when you need to capture response-specific headers + * (e.g. 'x-request-id', 'x-ratelimit-remaining') without capturing + * request headers. + * Optionally scoped to specific URLs via `forURLs`. + */ +export type MatchResponseHeadersRule = { + type: 'matchResponseHeaders'; + /** + * Response header names to capture. + * Preserved as-is; compared case-insensitively at capture time. + */ + headers: string[]; + /** + * URL patterns to scope this rule to. Supports hostname-only + * ('api.example.com') or hostname+path prefix ('api.example.com/v2'). + * Omit to match all URLs. + */ + forURLs?: string[]; +}; + +/** + * A composable header capture rule. + * + * Discriminated union on the `type` field. Multiple rules can be combined + * in an array -- matching rules are merged additively (union of headers). + */ +export type HeaderCaptureRule = + | DefaultsRule + | MatchHeadersRule + | MatchRequestHeadersRule + | MatchResponseHeadersRule; + /** * Optional RUM configuration values. */ @@ -71,6 +171,19 @@ export interface RumConfigurationOptions { */ firstPartyHosts?: FirstPartyHost[]; + /** + * Controls which resource headers the SDK captures on network requests. + * + * - **Omitted** (default): No headers are captured. + * - `'defaults'`: Shortcut equivalent to `[{ type: 'defaults' }]`. + * Captures a predefined set of caching and content headers. + * - `HeaderCaptureRule[]`: An array of composable rules. Multiple rules + * are merged additively -- matching rules contribute their headers. + * + * Requires `trackResources: true` to take effect. + */ + headerCaptureRules?: 'defaults' | HeaderCaptureRule[]; + /** * Initial resource collection threshold in seconds. */ diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index 88df95d80..3bc5f3d93 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -103,6 +103,13 @@ export { DdBabelInteractionTracking, __ddExtractText }; +export type { + HeaderCaptureRule, + DefaultsRule, + MatchHeadersRule, + MatchRequestHeadersRule, + MatchResponseHeadersRule +} from './config/features/RumConfiguration.type'; export type { Timestamp, FirstPartyHost, diff --git a/packages/core/src/rum/instrumentation/resourceTracking/DdRumResourceTracking.tsx b/packages/core/src/rum/instrumentation/resourceTracking/DdRumResourceTracking.tsx index 2a1c53e28..0d16020d8 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/DdRumResourceTracking.tsx +++ b/packages/core/src/rum/instrumentation/resourceTracking/DdRumResourceTracking.tsx @@ -7,12 +7,14 @@ import BigInt from 'big-integer'; import { InternalLog } from '../../../InternalLog'; +import type { HeaderCaptureRule } from '../../../config/features/RumConfiguration.type'; import { SdkVerbosity } from '../../../config/types/SdkVerbosity'; import { getGlobalInstance } from '../../../utils/singletonUtils'; import type { FirstPartyHost } from '../../types'; import { DistributedTracingSampling } from './distributedTracing/distributedTracingSampling'; import { firstPartyHostsRegexMapBuilder } from './distributedTracing/firstPartyHosts'; +import { compileHeaderCaptureConfig } from './headerCapture/compileHeaderConfig'; import { XHRProxy } from './requestProxy/XHRProxy/XHRProxy'; import type { RequestProxy } from './requestProxy/interfaces/RequestProxy'; @@ -40,10 +42,12 @@ class RumResourceTracking { */ startTracking({ resourceTraceSampleRate, - firstPartyHosts + firstPartyHosts, + headerCaptureRules }: { resourceTraceSampleRate: number; firstPartyHosts: FirstPartyHost[]; + headerCaptureRules?: 'defaults' | HeaderCaptureRule[] | undefined; }): void { // extra safety to avoid proxying the XHR class twice if (this._isTracking) { @@ -55,11 +59,15 @@ class RumResourceTracking { } this._requestProxy = XHRProxy.createWithResourceReporter(); + const headerCaptureConfig = compileHeaderCaptureConfig( + headerCaptureRules + ); this._requestProxy.onTrackingStart({ tracingSamplingRate: resourceTraceSampleRate, firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder( firstPartyHosts - ) + ), + headerCaptureConfig }); InternalLog.log( diff --git a/packages/core/src/rum/instrumentation/resourceTracking/__tests__/__utils__/XMLHttpRequestMock.ts b/packages/core/src/rum/instrumentation/resourceTracking/__tests__/__utils__/XMLHttpRequestMock.ts index 7683b63f9..5b62bcf34 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/__tests__/__utils__/XMLHttpRequestMock.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/__tests__/__utils__/XMLHttpRequestMock.ts @@ -27,7 +27,7 @@ export class XMLHttpRequestMock implements XMLHttpRequest { timeout: number = -1; upload: XMLHttpRequestUpload = {} as XMLHttpRequestUpload; withCredentials: boolean = false; - getAllResponseHeaders = jest.fn(); + getAllResponseHeaders = jest.fn().mockReturnValue(''); overrideMimeType = jest.fn(); DONE = 4 as const; HEADERS_RECEIVED = 2 as const; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/firstPartyHosts.ts b/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/firstPartyHosts.ts index 015adfc06..946c478fc 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/firstPartyHosts.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/distributedTracing/firstPartyHosts.ts @@ -15,7 +15,7 @@ export type Hostname = { _type: 'Hostname' } & string; export const NO_MATCH_REGEX = new RegExp('a^'); // From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions -const escapeRegExp = (string: string) => { +export const escapeRegExp = (string: string) => { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string }; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md new file mode 100644 index 000000000..cd77f76bc --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/README.md @@ -0,0 +1,142 @@ +# Resource Header Capture + +The Datadog React Native SDK can capture HTTP request and response headers on RUM resource events. This helps debug caching, CDN, and content negotiation issues without manual instrumentation. + +## Configuration + +Header capture is **disabled by default**. Enable it via the `trackResourceHeaders` option in your RUM configuration: + +```typescript +// Capture a predefined set of caching/content headers +trackResourceHeaders: 'defaults' + +// Capture specific headers for specific URLs +trackResourceHeaders: { + custom: [ + { + match: 'api.example.com', + requestHeaderNames: ['x-request-id'], + responseHeaderNames: ['etag', 'cache-control'] + } + ] +} + +// Disabled (default behavior) +trackResourceHeaders: 'disabled' +``` + +### Custom Rules + +Each rule specifies a URL pattern and the header names to capture: + +```typescript +type HeaderCaptureRule = { + match: string; // URL pattern (same format as firstPartyHosts) + requestHeaderNames?: string[]; // Request headers to capture + responseHeaderNames?: string[]; // Response headers to capture +}; +``` + +The `match` field supports hostname-only (`api.example.com`), hostname+path prefix (`api.example.com/v2`), or wildcard (`*`) to match all URLs. This is the same format used by `firstPartyHosts`. + +When multiple rules could match a URL, **the first matching rule wins**. + +### Default Headers + +When using `'defaults'` mode, the SDK captures: + +**10 response headers:** +`cache-control`, `etag`, `age`, `expires`, `content-type`, `content-encoding`, `content-length`, `vary`, `server-timing`, `x-cache` + +**2 request headers:** +`cache-control`, `content-type` + +## Output Format + +Captured headers appear as context attributes on `stopResource` events: + +- `_dd.request_headers` — `Record` of captured request headers +- `_dd.response_headers` — `Record` of captured response headers + +When no headers are captured (disabled mode, no matching URL rules, or empty result), these attributes are **omitted entirely** rather than set to empty objects. + +## Constraints and Behavior Details + +### Header Name Normalization + +All header names in the output are **lowercased**, regardless of how the server or application originally cased them. `Content-Type` and `content-type` both appear as `content-type` in the captured output. + +### Duplicate Headers + +- **Response headers:** If a response contains duplicate header names (e.g., multiple `Set-Cookie`), the **last value wins**. +- **Request headers:** If `setRequestHeader` is called multiple times with the same header name, the **last value wins**. + +### Whitespace Handling + +Leading and trailing whitespace is **trimmed** from header values. For example, `" text/html "` becomes `"text/html"`. + +### Malformed Response Headers + +Lines from `getAllResponseHeaders()` that lack a colon separator, have an empty name, or are otherwise malformed are **silently skipped**. The SDK never throws on malformed header input. + +### Null or Empty Responses + +If `getAllResponseHeaders()` returns `null` or an empty string (e.g., due to a network error or CORS restriction), the result is an empty object. Since empty results are omitted, no `_dd.response_headers` attribute will appear on the event. + +### Aborted Requests + +Response header capture is **skipped entirely** on aborted requests (XHR status 0). Request headers that were accumulated before the abort are also discarded. + +### Security Filtering + +The SDK enforces two layers of security filtering that **cannot be overridden** by configuration: + +#### Sensitive Header Blocklist + +Headers matching the following pattern are **always blocked**, even if explicitly listed in a custom rule: + +``` +/(?:token|cookie|secret|authorization|password|credential|bearer| +(?:api|secret|access|app).?key|forwarded|real.?ip|connecting.?ip|client.?ip)/i +``` + +This blocks headers like `Authorization`, `Cookie`, `Set-Cookie`, `x-access-token`, `x-amz-security-token`, `x-api-key`, `X-Forwarded-For`, `X-Real-IP`, and any header containing `password`, `secret`, `credential`, or `bearer` in its name. + +The match is **case-insensitive** and applies to partial name matches (a header named `x-custom-token-id` would be blocked because it contains `token`). + +#### SDK Tracing Header Exclusion + +The 12 headers injected by the SDK's distributed tracing system are **always excluded** from capture: + +| Header | Protocol | +|--------|----------| +| `x-datadog-sampling-priority` | Datadog | +| `x-datadog-origin` | Datadog | +| `x-datadog-trace-id` | Datadog | +| `x-datadog-parent-id` | Datadog | +| `x-datadog-tags` | Datadog | +| `traceparent` | W3C / OpenTelemetry | +| `tracestate` | W3C / OpenTelemetry | +| `baggage` | W3C / OpenTelemetry | +| `b3` | B3 Single | +| `x-b3-traceid` | B3 Multi | +| `x-b3-spanid` | B3 Multi | +| `x-b3-sampled` | B3 Multi | + +#### Filter Order + +Security filtering is applied **before** URL rule matching. The pipeline is: capture raw header -> security filter (blocklist + tracing exclusion) -> mode/rule filter (defaults set or custom rule). + +### Request Header Capture Timing + +Request headers are filtered by the security blocklist **at capture time** — sensitive headers are never stored in memory, even briefly. However, URL-based rule filtering is applied **at request completion** (when the final URL is known), since the URL may change between `setRequestHeader` calls and request completion. + +### Configuration Lifecycle + +- The compiled header capture configuration is **snapshotted per request** at `open()` time. In-flight requests are unaffected by configuration changes. +- Configuration changes take effect on the **next request** — no SDK restart required. +- When disabled, no interception hooks are installed for header capture — **zero overhead** on the network hot path. + +### Fetch Support + +React Native's `fetch` API uses `XMLHttpRequest` under the hood. All fetch requests go through the same XHR interception pipeline and benefit from header capture identically to direct XHR usage. diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts new file mode 100644 index 000000000..43ab426ba --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts @@ -0,0 +1,775 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { + DEFAULT_RESPONSE_HEADERS, + DEFAULT_REQUEST_HEADERS, + CANONICAL_RESPONSE_HEADERS, + CANONICAL_REQUEST_HEADERS, + accumulateRequestHeader, + captureResponseHeaders, + filterRequestHeadersByMode +} from '../captureHeaders'; +import type { CompiledHeaderCaptureConfig } from '../types'; + +describe('captureHeaders', () => { + describe('DEFAULT_RESPONSE_HEADERS', () => { + it('contains exactly 10 header names', () => { + expect(DEFAULT_RESPONSE_HEADERS.size).toBe(10); + }); + + it.each([ + 'cache-control', + 'etag', + 'age', + 'expires', + 'content-type', + 'content-encoding', + 'content-length', + 'vary', + 'server-timing', + 'x-cache' + ])('contains %s', header => { + expect(DEFAULT_RESPONSE_HEADERS.has(header)).toBe(true); + }); + }); + + describe('DEFAULT_REQUEST_HEADERS', () => { + it('contains exactly 2 header names', () => { + expect(DEFAULT_REQUEST_HEADERS.size).toBe(2); + }); + + it.each(['cache-control', 'content-type'])('contains %s', header => { + expect(DEFAULT_REQUEST_HEADERS.has(header)).toBe(true); + }); + }); + + describe('accumulateRequestHeader', () => { + it('stores an allowed header with original casing', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'Content-Type', 'application/json'); + expect(acc).toEqual({ 'Content-Type': 'application/json' }); + }); + + it('blocks sensitive headers (authorization)', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'Authorization', 'Bearer xyz'); + expect(acc).toEqual({}); + }); + + it('blocks tracing headers (x-datadog-trace-id)', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'x-datadog-trace-id', '123'); + expect(acc).toEqual({}); + }); + + it('uses last-value-wins for duplicate header names', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'Content-Type', 'application/json'); + accumulateRequestHeader(acc, 'Content-Type', 'text/html'); + expect(acc).toEqual({ 'Content-Type': 'text/html' }); + }); + + it('preserves custom header casing', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'X-Custom', 'val'); + expect(acc).toEqual({ 'X-Custom': 'val' }); + }); + + it('accumulates multiple allowed headers', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'Content-Type', 'application/json'); + accumulateRequestHeader(acc, 'Accept', 'text/html'); + expect(acc).toEqual({ + 'Content-Type': 'application/json', + Accept: 'text/html' + }); + }); + + it('preserves original setRequestHeader casing in accumulator key', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'Content-Type', 'application/json'); + expect(acc).toEqual({ 'Content-Type': 'application/json' }); + }); + + it('preserves mixed-case custom header name', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'X-Request-ID', 'abc'); + expect(acc).toEqual({ 'X-Request-ID': 'abc' }); + }); + + it('last-value-wins preserves latest casing', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'content-type', 'first'); + accumulateRequestHeader(acc, 'Content-Type', 'second'); + expect(acc).toEqual({ 'Content-Type': 'second' }); + expect(Object.keys(acc)).toEqual(['Content-Type']); + }); + + it('still blocks sensitive headers regardless of casing', () => { + const acc: Record = {}; + accumulateRequestHeader(acc, 'AUTHORIZATION', 'Bearer xyz'); + expect(acc).toEqual({}); + + accumulateRequestHeader(acc, 'Authorization', 'Bearer xyz'); + expect(acc).toEqual({}); + }); + }); + + describe('captureResponseHeaders', () => { + const defaultsConfig: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(DEFAULT_REQUEST_HEADERS), + responseHeaderNames: new Set(DEFAULT_RESPONSE_HEADERS), + isScoped: false, + requestHeaderCasing: new Map(CANONICAL_REQUEST_HEADERS), + responseHeaderCasing: new Map(CANONICAL_RESPONSE_HEADERS) + } + ]; + const disabledConfig: CompiledHeaderCaptureConfig = null; + const testUrl = 'https://api.example.com/data'; + + it('returns undefined when config is null (disabled)', () => { + const result = captureResponseHeaders( + 'content-type: text/html\r\n', + testUrl, + disabledConfig + ); + expect(result).toBeUndefined(); + }); + + it('returns undefined when rawHeaders is null', () => { + const result = captureResponseHeaders( + null, + testUrl, + defaultsConfig + ); + expect(result).toBeUndefined(); + }); + + it('returns undefined when rawHeaders is undefined', () => { + const result = captureResponseHeaders( + undefined, + testUrl, + defaultsConfig + ); + expect(result).toBeUndefined(); + }); + + it('returns undefined when rawHeaders is empty string', () => { + const result = captureResponseHeaders('', testUrl, defaultsConfig); + expect(result).toBeUndefined(); + }); + + it('captures default response headers with defaults rule', () => { + const raw = + 'content-type: text/html\r\ncache-control: no-cache\r\n'; + const result = captureResponseHeaders(raw, testUrl, defaultsConfig); + expect(result).toEqual({ + 'Content-Type': 'text/html', + 'Cache-Control': 'no-cache' + }); + }); + + it('filters out non-default headers with defaults rule', () => { + const raw = 'x-custom: val\r\n'; + const result = captureResponseHeaders(raw, testUrl, defaultsConfig); + expect(result).toBeUndefined(); + }); + + it('filters out sensitive headers via security filter', () => { + const raw = 'authorization: secret\r\ncache-control: no-cache\r\n'; + const result = captureResponseHeaders(raw, testUrl, defaultsConfig); + expect(result).toEqual({ 'Cache-Control': 'no-cache' }); + }); + + it('returns undefined when all headers are sensitive', () => { + const raw = 'authorization: secret\r\ncookie: session=abc\r\n'; + const result = captureResponseHeaders(raw, testUrl, defaultsConfig); + expect(result).toBeUndefined(); + }); + + it('captures all 10 default response headers when present', () => { + const raw = `${[ + 'cache-control: max-age=3600', + 'etag: "abc123"', + 'age: 100', + 'expires: Thu, 01 Jan 2099 00:00:00 GMT', + 'content-type: application/json', + 'content-encoding: gzip', + 'content-length: 1234', + 'vary: Accept-Encoding', + 'server-timing: db;dur=53', + 'x-cache: HIT' + ].join('\r\n')}\r\n`; + const result = captureResponseHeaders(raw, testUrl, defaultsConfig); + expect(result).toEqual({ + 'Cache-Control': 'max-age=3600', + ETag: '"abc123"', + Age: '100', + Expires: 'Thu, 01 Jan 2099 00:00:00 GMT', + 'Content-Type': 'application/json', + 'Content-Encoding': 'gzip', + 'Content-Length': '1234', + Vary: 'Accept-Encoding', + 'Server-Timing': 'db;dur=53', + 'X-Cache': 'HIT' + }); + }); + + describe('scoped rules', () => { + it('captures only headers from matching rule', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /^https?:\/\/api\.example\.com/, + requestHeaderNames: new Set(['x-request-id']), + responseHeaderNames: new Set([ + 'x-custom', + 'content-type' + ]), + isScoped: true, + requestHeaderCasing: new Map([ + ['x-request-id', 'x-request-id'] + ]), + responseHeaderCasing: new Map([ + ['x-custom', 'x-custom'], + ['content-type', 'content-type'] + ]) + } + ]; + const raw = + 'x-custom: val\r\ncontent-type: text/html\r\ncache-control: no-cache\r\n'; + const result = captureResponseHeaders(raw, testUrl, config); + expect(result).toEqual({ + 'x-custom': 'val', + 'content-type': 'text/html' + }); + }); + + it('returns undefined when no rule matches the URL', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /^https?:\/\/other\.example\.com/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['content-type']), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['content-type', 'content-type'] + ]) + } + ]; + const raw = 'content-type: text/html\r\n'; + const result = captureResponseHeaders(raw, testUrl, config); + expect(result).toBeUndefined(); + }); + + it('still applies security filter with scoped rules', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /^https?:\/\/api\.example\.com/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set([ + 'authorization', + 'content-type' + ]), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['authorization', 'authorization'], + ['content-type', 'content-type'] + ]) + } + ]; + const raw = + 'authorization: secret\r\ncontent-type: text/html\r\n'; + const result = captureResponseHeaders(raw, testUrl, config); + expect(result).toEqual({ 'content-type': 'text/html' }); + }); + }); + + describe('union merging', () => { + it('merges headers from multiple matching rules additively', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /api\.example\.com/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['x-cache']), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([['x-cache', 'x-cache']]) + }, + { + urlRegex: /api\.example\.com/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['content-type']), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['content-type', 'content-type'] + ]) + } + ]; + const raw = 'x-cache: HIT\r\ncontent-type: text/html\r\n'; + const result = captureResponseHeaders(raw, testUrl, config); + expect(result).toEqual({ + 'x-cache': 'HIT', + 'content-type': 'text/html' + }); + }); + + it('scoped rules replace catch-all rules when both match', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['etag']), + isScoped: false, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([['etag', 'etag']]) + }, + { + urlRegex: /api\.example\.com/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['x-cache']), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([['x-cache', 'x-cache']]) + } + ]; + const raw = 'etag: "abc"\r\nx-cache: HIT\r\n'; + const result = captureResponseHeaders(raw, testUrl, config); + expect(result).toEqual({ 'x-cache': 'HIT' }); + }); + + it('catch-all rules apply when no scoped rule matches', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['etag']), + isScoped: false, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([['etag', 'etag']]) + }, + { + urlRegex: /other\.com/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['x-cache']), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([['x-cache', 'x-cache']]) + } + ]; + const raw = 'etag: "abc"\r\nx-cache: HIT\r\n'; + const result = captureResponseHeaders( + raw, + 'https://api.example.com/data', + config + ); + expect(result).toEqual({ etag: '"abc"' }); + }); + + it('multiple scoped rules merge additively', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /api\.example\.com/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['x-cache', 'vary']), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['x-cache', 'x-cache'], + ['vary', 'vary'] + ]) + }, + { + urlRegex: /api\.example\.com/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['content-type', 'etag']), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['content-type', 'content-type'], + ['etag', 'etag'] + ]) + } + ]; + const raw = + 'x-cache: HIT\r\nvary: Accept\r\ncontent-type: text/html\r\netag: "abc"\r\n'; + const result = captureResponseHeaders(raw, testUrl, config); + expect(result).toEqual({ + 'x-cache': 'HIT', + vary: 'Accept', + 'content-type': 'text/html', + etag: '"abc"' + }); + }); + }); + + describe('casing preservation', () => { + it('outputs default headers with canonical Title-Case casing', () => { + const raw = + 'content-type: text/html\r\ncache-control: no-cache\r\n'; + const result = captureResponseHeaders( + raw, + testUrl, + defaultsConfig + ); + expect(result).toEqual({ + 'Content-Type': 'text/html', + 'Cache-Control': 'no-cache' + }); + }); + + it('outputs custom rule headers with user-provided casing', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['x-ratelimit']), + isScoped: false, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['x-ratelimit', 'x-ratelimit'] + ]) + } + ]; + const raw = 'x-ratelimit: 100\r\n'; + const result = captureResponseHeaders(raw, testUrl, config); + expect(result).toEqual({ 'x-ratelimit': '100' }); + }); + + it('custom rule casing overrides defaults for same header', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(DEFAULT_REQUEST_HEADERS), + responseHeaderNames: new Set(DEFAULT_RESPONSE_HEADERS), + isScoped: false, + requestHeaderCasing: new Map(CANONICAL_REQUEST_HEADERS), + responseHeaderCasing: new Map( + CANONICAL_RESPONSE_HEADERS + ) + }, + { + urlRegex: /.*/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['content-type']), + isScoped: false, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['content-type', 'content-type'] + ]) + } + ]; + const raw = 'content-type: text/html\r\n'; + const result = captureResponseHeaders(raw, testUrl, config); + // Second rule's casing should override first for same header + expect(result).toEqual({ 'content-type': 'text/html' }); + }); + + it('first custom rule casing wins among custom rules', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['content-type']), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['content-type', 'Content-Type'] + ]) + }, + { + urlRegex: /.*/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set(['content-type']), + isScoped: true, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['content-type', 'content-type'] + ]) + } + ]; + const raw = 'content-type: text/html\r\n'; + const result = captureResponseHeaders(raw, testUrl, config); + // First rule's casing wins + expect(result).toEqual({ 'Content-Type': 'text/html' }); + }); + }); + }); + + describe('filterRequestHeadersByMode', () => { + const testUrl = 'https://api.example.com/data'; + const disabledConfig: CompiledHeaderCaptureConfig = null; + const defaultsConfig: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(DEFAULT_REQUEST_HEADERS), + responseHeaderNames: new Set(DEFAULT_RESPONSE_HEADERS), + isScoped: false, + requestHeaderCasing: new Map(CANONICAL_REQUEST_HEADERS), + responseHeaderCasing: new Map(CANONICAL_RESPONSE_HEADERS) + } + ]; + + it('returns undefined when config is null (disabled)', () => { + const result = filterRequestHeadersByMode( + { 'content-type': 'json' }, + testUrl, + disabledConfig + ); + expect(result).toBeUndefined(); + }); + + it('keeps only default request headers with defaults rule', () => { + const result = filterRequestHeadersByMode( + { 'content-type': 'json', 'x-custom': 'val' }, + testUrl, + defaultsConfig + ); + expect(result).toEqual({ 'Content-Type': 'json' }); + }); + + it('returns undefined when no default request headers present', () => { + const result = filterRequestHeadersByMode( + { 'x-custom': 'val' }, + testUrl, + defaultsConfig + ); + expect(result).toBeUndefined(); + }); + + it('keeps both default request headers when present', () => { + const result = filterRequestHeadersByMode( + { + 'content-type': 'json', + 'cache-control': 'no-cache', + 'x-custom': 'val' + }, + testUrl, + defaultsConfig + ); + expect(result).toEqual({ + 'Content-Type': 'json', + 'Cache-Control': 'no-cache' + }); + }); + + describe('scoped rules', () => { + it('keeps only request headers from matching rule', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /^https?:\/\/api\.example\.com/, + requestHeaderNames: new Set(['x-request-id']), + responseHeaderNames: new Set(), + isScoped: true, + requestHeaderCasing: new Map([ + ['x-request-id', 'x-request-id'] + ]), + responseHeaderCasing: new Map() + } + ]; + const result = filterRequestHeadersByMode( + { 'x-request-id': 'abc', 'x-other': 'def' }, + testUrl, + config + ); + expect(result).toEqual({ 'x-request-id': 'abc' }); + }); + + it('returns undefined when no rule matches URL', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /^https?:\/\/other\.example\.com/, + requestHeaderNames: new Set(['x-request-id']), + responseHeaderNames: new Set(), + isScoped: true, + requestHeaderCasing: new Map([ + ['x-request-id', 'x-request-id'] + ]), + responseHeaderCasing: new Map() + } + ]; + const result = filterRequestHeadersByMode( + { 'x-request-id': 'abc' }, + testUrl, + config + ); + expect(result).toBeUndefined(); + }); + + it('returns undefined when matching rule has no request header matches', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /^https?:\/\/api\.example\.com/, + requestHeaderNames: new Set(['x-request-id']), + responseHeaderNames: new Set(), + isScoped: true, + requestHeaderCasing: new Map([ + ['x-request-id', 'x-request-id'] + ]), + responseHeaderCasing: new Map() + } + ]; + const result = filterRequestHeadersByMode( + { 'x-other': 'val' }, + testUrl, + config + ); + expect(result).toBeUndefined(); + }); + }); + + describe('union merging', () => { + it('merges request headers from multiple matching rules', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /api\.example\.com/, + requestHeaderNames: new Set(['x-request-id']), + responseHeaderNames: new Set(), + isScoped: true, + requestHeaderCasing: new Map([ + ['x-request-id', 'x-request-id'] + ]), + responseHeaderCasing: new Map() + }, + { + urlRegex: /api\.example\.com/, + requestHeaderNames: new Set(['content-type']), + responseHeaderNames: new Set(), + isScoped: true, + requestHeaderCasing: new Map([ + ['content-type', 'content-type'] + ]), + responseHeaderCasing: new Map() + } + ]; + const result = filterRequestHeadersByMode( + { + 'x-request-id': 'abc', + 'content-type': 'json', + 'x-other': 'val' + }, + testUrl, + config + ); + expect(result).toEqual({ + 'x-request-id': 'abc', + 'content-type': 'json' + }); + }); + + it('scoped rules replace catch-all for request headers', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(['x-catch-all']), + responseHeaderNames: new Set(), + isScoped: false, + requestHeaderCasing: new Map([ + ['x-catch-all', 'x-catch-all'] + ]), + responseHeaderCasing: new Map() + }, + { + urlRegex: /api\.example\.com/, + requestHeaderNames: new Set(['x-scoped']), + responseHeaderNames: new Set(), + isScoped: true, + requestHeaderCasing: new Map([ + ['x-scoped', 'x-scoped'] + ]), + responseHeaderCasing: new Map() + } + ]; + const result = filterRequestHeadersByMode( + { 'x-catch-all': 'a', 'x-scoped': 'b' }, + testUrl, + config + ); + expect(result).toEqual({ 'x-scoped': 'b' }); + }); + }); + + it('returns undefined for empty accumulated headers', () => { + const result = filterRequestHeadersByMode( + {}, + testUrl, + defaultsConfig + ); + expect(result).toBeUndefined(); + }); + + describe('casing preservation', () => { + it('outputs request headers with original accumulated casing', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set([ + 'content-type', + 'x-request-id' + ]), + responseHeaderNames: new Set(), + isScoped: false, + requestHeaderCasing: new Map([ + ['content-type', 'Content-Type'], + ['x-request-id', 'X-Request-ID'] + ]), + responseHeaderCasing: new Map() + } + ]; + // Accumulated headers have original setRequestHeader casing + const result = filterRequestHeadersByMode( + { + 'Content-Type': 'application/json', + 'X-Request-ID': 'abc' + }, + testUrl, + config + ); + expect(result).toEqual({ + 'Content-Type': 'application/json', + 'X-Request-ID': 'abc' + }); + }); + + it('falls back to config casing when accumulated key is lowered', () => { + const config: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set([ + 'content-type', + 'cache-control' + ]), + responseHeaderNames: new Set(), + isScoped: false, + requestHeaderCasing: new Map([ + ['content-type', 'Content-Type'], + ['cache-control', 'Cache-Control'] + ]), + responseHeaderCasing: new Map() + } + ]; + // Accumulated headers are lowered (legacy/fallback scenario) + const result = filterRequestHeadersByMode( + { + 'content-type': 'application/json', + 'cache-control': 'no-cache' + }, + testUrl, + config + ); + expect(result).toEqual({ + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache' + }); + }); + }); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts new file mode 100644 index 000000000..cae73e9e6 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts @@ -0,0 +1,430 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { InternalLog } from '../../../../../InternalLog'; +import { SdkVerbosity } from '../../../../../config/types/SdkVerbosity'; +import { + DEFAULT_REQUEST_HEADERS, + DEFAULT_RESPONSE_HEADERS, + CANONICAL_REQUEST_HEADERS, + CANONICAL_RESPONSE_HEADERS +} from '../captureHeaders'; +import { compileHeaderCaptureConfig } from '../compileHeaderConfig'; + +jest.mock('../../../../../InternalLog', () => ({ + InternalLog: { + log: jest.fn() + } +})); + +const mockLog = InternalLog.log as jest.Mock; + +beforeEach(() => { + mockLog.mockClear(); +}); + +describe('compileHeaderCaptureConfig', () => { + describe('disabled / null output', () => { + it('returns null for undefined', () => { + expect(compileHeaderCaptureConfig(undefined)).toBeNull(); + }); + + it('returns null for empty array and logs WARN', () => { + const result = compileHeaderCaptureConfig([]); + expect(result).toBeNull(); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining( + 'headerCaptureRules is empty, no headers will be captured' + ), + SdkVerbosity.WARN + ); + }); + + it('returns null for totally invalid value and logs WARN', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = compileHeaderCaptureConfig( + 'totally-invalid-value' as any + ); + expect(result).toBeNull(); + expect(mockLog).toHaveBeenCalledWith( + expect.any(String), + SdkVerbosity.WARN + ); + }); + }); + + describe('"defaults" string shortcut', () => { + it('returns an array with exactly 1 rule', () => { + const result = compileHeaderCaptureConfig('defaults'); + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + }); + + it('has requestHeaderNames equal to DEFAULT_REQUEST_HEADERS', () => { + const result = compileHeaderCaptureConfig('defaults'); + expect(result).not.toBeNull(); + expect(result![0].requestHeaderNames).toEqual( + new Set(DEFAULT_REQUEST_HEADERS) + ); + }); + + it('has responseHeaderNames equal to DEFAULT_RESPONSE_HEADERS', () => { + const result = compileHeaderCaptureConfig('defaults'); + expect(result).not.toBeNull(); + expect(result![0].responseHeaderNames).toEqual( + new Set(DEFAULT_RESPONSE_HEADERS) + ); + }); + + it('has urlRegex matching any URL', () => { + const result = compileHeaderCaptureConfig('defaults'); + expect(result).not.toBeNull(); + expect(result![0].urlRegex.test('https://anything.com/path')).toBe( + true + ); + }); + + it('has isScoped equal to false', () => { + const result = compileHeaderCaptureConfig('defaults'); + expect(result).not.toBeNull(); + expect(result![0].isScoped).toBe(false); + }); + }); + + describe('array input — rule variants', () => { + it('compiles a defaults rule with default header sets', () => { + const result = compileHeaderCaptureConfig([{ type: 'defaults' }]); + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + const rule = result![0]; + expect(rule.requestHeaderNames).toEqual( + new Set(DEFAULT_REQUEST_HEADERS) + ); + expect(rule.responseHeaderNames).toEqual( + new Set(DEFAULT_RESPONSE_HEADERS) + ); + expect(rule.urlRegex.test('https://anything.com/path')).toBe(true); + expect(rule.isScoped).toBe(false); + }); + + it('compiles a matchHeaders rule with both request and response sets', () => { + const result = compileHeaderCaptureConfig([ + { type: 'matchHeaders', headers: ['Content-Type', 'X-Custom'] } + ]); + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + const rule = result![0]; + expect(rule.requestHeaderNames).toEqual( + new Set(['content-type', 'x-custom']) + ); + expect(rule.responseHeaderNames).toEqual( + new Set(['content-type', 'x-custom']) + ); + }); + + it('compiles a matchRequestHeaders rule with request set only', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchRequestHeaders', + headers: ['X-Request-ID', 'Authorization'] + } + ]); + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + const rule = result![0]; + expect(rule.requestHeaderNames).toEqual( + new Set(['x-request-id', 'authorization']) + ); + expect(rule.responseHeaderNames).toEqual(new Set()); + }); + + it('compiles a matchResponseHeaders rule with response set only', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['ETag', 'X-RateLimit-Remaining'] + } + ]); + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + const rule = result![0]; + expect(rule.requestHeaderNames).toEqual(new Set()); + expect(rule.responseHeaderNames).toEqual( + new Set(['etag', 'x-ratelimit-remaining']) + ); + }); + + it('lowercases header names regardless of input casing', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchHeaders', + headers: ['X-Request-ID', 'AUTHORIZATION', 'Cache-Control'] + } + ]); + expect(result).not.toBeNull(); + expect(result![0].requestHeaderNames).toEqual( + new Set(['x-request-id', 'authorization', 'cache-control']) + ); + expect(result![0].responseHeaderNames).toEqual( + new Set(['x-request-id', 'authorization', 'cache-control']) + ); + }); + + it('{ type: "defaults" } in array produces rule with isScoped: false', () => { + const result = compileHeaderCaptureConfig([{ type: 'defaults' }]); + expect(result).not.toBeNull(); + expect(result![0].isScoped).toBe(false); + }); + + it('matchHeaders with specific forURLs produces rule with isScoped: true', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchHeaders', + headers: ['etag'], + forURLs: ['api.example.com'] + } + ]); + expect(result).not.toBeNull(); + expect(result![0].isScoped).toBe(true); + }); + }); + + describe('forURLs handling', () => { + it('omitting forURLs produces catch-all regex and isScoped: false', () => { + const result = compileHeaderCaptureConfig([ + { type: 'matchResponseHeaders', headers: ['etag'] } + ]); + expect(result).not.toBeNull(); + const { urlRegex, isScoped } = result![0]; + expect(urlRegex.test('https://anything.com/path')).toBe(true); + expect(urlRegex.test('http://other.io')).toBe(true); + expect(isScoped).toBe(false); + }); + + it('forURLs: ["*"] produces catch-all regex and isScoped: false', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['*'] + } + ]); + expect(result).not.toBeNull(); + const { urlRegex, isScoped } = result![0]; + expect(urlRegex.test('https://anything.com/path')).toBe(true); + expect(urlRegex.test('')).toBe(true); + expect(isScoped).toBe(false); + }); + + it('specific forURLs patterns produce isScoped: true', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com'] + } + ]); + expect(result).not.toBeNull(); + const { urlRegex, isScoped } = result![0]; + expect(urlRegex.test('https://api.example.com/any')).toBe(true); + expect(urlRegex.test('https://api.example.com')).toBe(true); + expect(urlRegex.test('https://other.com/api')).toBe(false); + expect(isScoped).toBe(true); + }); + + it('forURLs: ["api.example.com/v2"] matches /v2 paths but not /v1', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com/v2'] + } + ]); + expect(result).not.toBeNull(); + const { urlRegex } = result![0]; + expect(urlRegex.test('https://api.example.com/v2/users')).toBe( + true + ); + expect(urlRegex.test('https://api.example.com/v1/users')).toBe( + false + ); + }); + + it('forURLs: [] (empty) skips the rule with WARN log', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: [] + } + ]); + // With only one rule that gets skipped, all rules fail -> null + expect(result).toBeNull(); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('empty forURLs'), + SdkVerbosity.WARN + ); + }); + + it('forURLs with invalid pattern skips the rule with WARN log', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['[invalid'] + } + ]); + // With only one rule that gets skipped, all rules fail -> null + expect(result).toBeNull(); + expect(mockLog).toHaveBeenCalledWith( + expect.any(String), + SdkVerbosity.WARN + ); + }); + + it('forURLs with multiple patterns produces combined regex', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com', 'cdn.example.com'] + } + ]); + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + const { urlRegex } = result![0]; + expect(urlRegex.test('https://api.example.com/v2')).toBe(true); + expect(urlRegex.test('https://cdn.example.com/assets')).toBe(true); + expect(urlRegex.test('https://other.com')).toBe(false); + }); + }); + + describe('multiple rules', () => { + it('compiles multiple rules of different types', () => { + const result = compileHeaderCaptureConfig([ + { type: 'defaults' }, + { + type: 'matchHeaders', + headers: ['X-Custom'], + forURLs: ['api.example.com'] + }, + { type: 'matchRequestHeaders', headers: ['Authorization'] }, + { type: 'matchResponseHeaders', headers: ['ETag'] } + ]); + expect(result).not.toBeNull(); + expect(result).toHaveLength(4); + }); + + it('keeps valid rules and skips invalid ones', () => { + const result = compileHeaderCaptureConfig([ + { type: 'matchResponseHeaders', headers: ['etag'] }, + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: [] + } // skipped + ]); + expect(result).not.toBeNull(); + expect(result).toHaveLength(1); + expect(mockLog).toHaveBeenCalledTimes(1); + }); + }); + + describe('casing maps', () => { + it('"defaults" shortcut produces canonical casing maps', () => { + const result = compileHeaderCaptureConfig('defaults'); + expect(result).not.toBeNull(); + const rule = result![0]; + expect(rule.requestHeaderCasing).toEqual( + new Map(CANONICAL_REQUEST_HEADERS) + ); + expect(rule.responseHeaderCasing).toEqual( + new Map(CANONICAL_RESPONSE_HEADERS) + ); + }); + + it('{ type: "defaults" } rule produces canonical casing maps', () => { + const result = compileHeaderCaptureConfig([{ type: 'defaults' }]); + expect(result).not.toBeNull(); + const rule = result![0]; + expect(rule.requestHeaderCasing).toEqual( + new Map(CANONICAL_REQUEST_HEADERS) + ); + expect(rule.responseHeaderCasing).toEqual( + new Map(CANONICAL_RESPONSE_HEADERS) + ); + }); + + it('matchHeaders preserves user casing in both maps', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchHeaders', + headers: ['Content-Type', 'X-Custom'] + } + ]); + expect(result).not.toBeNull(); + const rule = result![0]; + expect(rule.requestHeaderCasing).toEqual( + new Map([ + ['content-type', 'Content-Type'], + ['x-custom', 'X-Custom'] + ]) + ); + expect(rule.responseHeaderCasing).toEqual( + new Map([ + ['content-type', 'Content-Type'], + ['x-custom', 'X-Custom'] + ]) + ); + }); + + it('matchRequestHeaders populates requestHeaderCasing only', () => { + const result = compileHeaderCaptureConfig([ + { type: 'matchRequestHeaders', headers: ['X-Api-Key'] } + ]); + expect(result).not.toBeNull(); + const rule = result![0]; + expect(rule.requestHeaderCasing).toEqual( + new Map([['x-api-key', 'X-Api-Key']]) + ); + expect(rule.responseHeaderCasing).toEqual(new Map()); + }); + + it('matchResponseHeaders populates responseHeaderCasing only', () => { + const result = compileHeaderCaptureConfig([ + { type: 'matchResponseHeaders', headers: ['X-RateLimit'] } + ]); + expect(result).not.toBeNull(); + const rule = result![0]; + expect(rule.requestHeaderCasing).toEqual(new Map()); + expect(rule.responseHeaderCasing).toEqual( + new Map([['x-ratelimit', 'X-RateLimit']]) + ); + }); + + it('each rule carries its own casing map independently', () => { + const result = compileHeaderCaptureConfig([ + { type: 'defaults' }, + { + type: 'matchResponseHeaders', + headers: ['content-type'] + } + ]); + expect(result).not.toBeNull(); + expect(result).toHaveLength(2); + + // Defaults rule has canonical Title-Case + expect(result![0].responseHeaderCasing.get('content-type')).toBe( + 'Content-Type' + ); + + // Custom rule preserves user-provided lowercase + expect(result![1].responseHeaderCasing.get('content-type')).toBe( + 'content-type' + ); + }); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.integration.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.integration.test.ts new file mode 100644 index 000000000..342daf7bd --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.integration.test.ts @@ -0,0 +1,183 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { NativeModules } from 'react-native'; + +import { BufferSingleton } from '../../../../../sdk/DatadogProvider/Buffer/BufferSingleton'; +import { XMLHttpRequestMock } from '../../__tests__/__utils__/XMLHttpRequestMock'; +import { firstPartyHostsRegexMapBuilder } from '../../distributedTracing/firstPartyHosts'; +import { ResourceReporter } from '../../requestProxy/XHRProxy/DatadogRumResource/ResourceReporter'; +import { XHRProxy } from '../../requestProxy/XHRProxy/XHRProxy'; +import { + DEFAULT_REQUEST_HEADERS, + DEFAULT_RESPONSE_HEADERS, + CANONICAL_REQUEST_HEADERS, + CANONICAL_RESPONSE_HEADERS +} from '../captureHeaders'; +import { MAX_HEADER_VALUE_BYTES } from '../enforceSizeLimits'; +import type { CompiledHeaderCaptureConfig } from '../types'; + +jest.useFakeTimers(); + +const DdNativeRum = NativeModules.DdRum; + +const flushPromises = () => + new Promise(jest.requireActual('timers').setImmediate); + +let xhrProxy: any; + +beforeEach(() => { + DdNativeRum.startResource.mockClear(); + DdNativeRum.stopResource.mockClear(); + BufferSingleton.onInitialization(); + + xhrProxy = new XHRProxy({ + xhrType: XMLHttpRequestMock, + resourceReporter: new ResourceReporter([]) + } as { + xhrType: typeof XMLHttpRequest; + resourceReporter: ResourceReporter; + }); + + let now = Date.now(); + jest.spyOn(Date, 'now').mockImplementation(() => { + now += 5; + return now; + }); +}); + +afterEach(() => { + xhrProxy.onTrackingStop(); + (Date.now as jest.MockedFunction).mockClear(); +}); + +describe('enforceSizeLimits integration', () => { + it('truncates oversized header values before they reach stopResource', async () => { + // GIVEN - a value longer than MAX_HEADER_VALUE_BYTES + const longValue = 'x'.repeat(256); + const method = 'GET'; + const url = 'https://api.example.com/data'; + const defaultsConfig: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(DEFAULT_REQUEST_HEADERS), + responseHeaderNames: new Set(DEFAULT_RESPONSE_HEADERS), + isScoped: false, + requestHeaderCasing: new Map(CANONICAL_REQUEST_HEADERS), + responseHeaderCasing: new Map(CANONICAL_RESPONSE_HEADERS) + } + ]; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: defaultsConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Content-Type', longValue); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + `content-type: ${longValue}\r\n` + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN - values are truncated to MAX_HEADER_VALUE_BYTES + const stopContext = DdNativeRum.stopResource.mock.calls[0][4]; + expect(stopContext['_dd.request_headers']['Content-Type'].length).toBe( + MAX_HEADER_VALUE_BYTES + ); + expect(stopContext['_dd.response_headers']['Content-Type'].length).toBe( + MAX_HEADER_VALUE_BYTES + ); + }); + + it('disabled config (null): enforceSizeLimits is never invoked', async () => { + // GIVEN + const enforceSizeLimitsModule = require('../enforceSizeLimits'); + const spy = jest.spyOn(enforceSizeLimitsModule, 'enforceSizeLimits'); + + const method = 'GET'; + const url = 'https://api.example.com/data'; + const disabledConfig: CompiledHeaderCaptureConfig = null; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: disabledConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'content-type: text/html\r\n' + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN - enforceSizeLimits was NOT called (zero overhead) + expect(spy).not.toHaveBeenCalled(); + + // Also verify no headers in stopResource context + const stopContext = DdNativeRum.stopResource.mock.calls[0][4]; + expect(stopContext['_dd.request_headers']).toBeUndefined(); + expect(stopContext['_dd.response_headers']).toBeUndefined(); + + spy.mockRestore(); + }); + + it('silent operation: no console warnings or errors during enforcement', async () => { + // GIVEN - oversized headers that will trigger truncation + const longValue = 'y'.repeat(256); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(); + + const method = 'GET'; + const url = 'https://api.example.com/data'; + const defaultsConfig: CompiledHeaderCaptureConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(DEFAULT_REQUEST_HEADERS), + responseHeaderNames: new Set(DEFAULT_RESPONSE_HEADERS), + isScoped: false, + requestHeaderCasing: new Map(CANONICAL_REQUEST_HEADERS), + responseHeaderCasing: new Map(CANONICAL_RESPONSE_HEADERS) + } + ]; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: defaultsConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Content-Type', longValue); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + `content-type: ${longValue}\r\n` + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN - no console output during enforcement + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.test.ts new file mode 100644 index 000000000..a899652ae --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.test.ts @@ -0,0 +1,226 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { + enforceSizeLimits, + MAX_HEADER_VALUE_BYTES, + MAX_HEADER_COUNT, + MAX_TOTAL_BYTES +} from '../enforceSizeLimits'; + +describe('enforceSizeLimits', () => { + describe('constants', () => { + it('exports MAX_HEADER_VALUE_BYTES as 128', () => { + expect(MAX_HEADER_VALUE_BYTES).toBe(128); + }); + + it('exports MAX_HEADER_COUNT as 100', () => { + expect(MAX_HEADER_COUNT).toBe(100); + }); + + it('exports MAX_TOTAL_BYTES as 2048', () => { + expect(MAX_TOTAL_BYTES).toBe(2048); + }); + }); + + describe('undefined passthrough', () => { + it('returns both undefined when both inputs are undefined', () => { + const result = enforceSizeLimits(undefined, undefined); + expect(result.requestHeaders).toBeUndefined(); + expect(result.responseHeaders).toBeUndefined(); + }); + + it('passes through small request headers when response is undefined', () => { + const result = enforceSizeLimits({ a: 'b' }, undefined); + expect(result.requestHeaders).toEqual({ a: 'b' }); + expect(result.responseHeaders).toBeUndefined(); + }); + + it('passes through small response headers when request is undefined', () => { + const result = enforceSizeLimits(undefined, { a: 'b' }); + expect(result.requestHeaders).toBeUndefined(); + expect(result.responseHeaders).toEqual({ a: 'b' }); + }); + }); + + describe('value truncation', () => { + it('truncates header values longer than 128 bytes', () => { + const longValue = 'a'.repeat(200); + const result = enforceSizeLimits({ x: longValue }, undefined); + expect(result.requestHeaders!['x']).toBe('a'.repeat(128)); + expect(result.requestHeaders!['x'].length).toBe(128); + }); + + it('does not truncate values exactly at 128 bytes', () => { + const exactValue = 'a'.repeat(128); + const result = enforceSizeLimits({ x: exactValue }, undefined); + expect(result.requestHeaders!['x']).toBe(exactValue); + }); + + it('truncates response header values as well', () => { + const longValue = 'b'.repeat(200); + const result = enforceSizeLimits(undefined, { + y: longValue + }); + expect(result.responseHeaders!['y']).toBe('b'.repeat(128)); + }); + }); + + describe('header count cap', () => { + it('keeps all headers when combined count is at 100', () => { + const req: Record = {}; + const res: Record = {}; + for (let i = 0; i < 50; i++) { + req[`rq${i}`] = 'v'; + res[`rs${i}`] = 'v'; + } + const result = enforceSizeLimits(req, res); + expect( + Object.keys(result.requestHeaders!).length + + Object.keys(result.responseHeaders!).length + ).toBe(100); + }); + + it('caps total to 100 with request headers taking priority', () => { + const req: Record = {}; + const res: Record = {}; + for (let i = 0; i < 60; i++) { + req[`rq${i}`] = 'v'; + res[`rs${i}`] = 'v'; + } + const result = enforceSizeLimits(req, res); + expect(Object.keys(result.requestHeaders!).length).toBe(60); + expect(Object.keys(result.responseHeaders!).length).toBe(40); + }); + + it('caps request-only headers to 100', () => { + const req: Record = {}; + for (let i = 0; i < 110; i++) { + req[`h${i}`] = 'v'; + } + const result = enforceSizeLimits(req, undefined); + expect(Object.keys(result.requestHeaders!).length).toBe(100); + expect(result.responseHeaders).toBeUndefined(); + }); + + it('returns undefined for response when all its headers are dropped by count cap', () => { + const req: Record = {}; + for (let i = 0; i < 100; i++) { + req[`h${i}`] = 'v'; + } + const res: Record = { a: 'b' }; + const result = enforceSizeLimits(req, res); + expect(Object.keys(result.requestHeaders!).length).toBe(100); + expect(result.responseHeaders).toBeUndefined(); + }); + }); + + describe('total size budget', () => { + it('drops response headers first when total exceeds 2048 bytes', () => { + // Each header: name(4) + value(100) = 104 bytes + // 20 headers = 2080 bytes > 2048 + const req: Record = {}; + const res: Record = {}; + for (let i = 0; i < 10; i++) { + req[`rq${String(i).padStart(2, '0')}`] = 'x'.repeat(100); + res[`rs${String(i).padStart(2, '0')}`] = 'x'.repeat(100); + } + const result = enforceSizeLimits(req, res); + // Request headers should be preserved; response headers dropped from end + expect(Object.keys(result.requestHeaders!).length).toBe(10); + // Some response headers should be dropped + const totalBytes = computeTotalBytes( + result.requestHeaders, + result.responseHeaders + ); + expect(totalBytes).toBeLessThanOrEqual(2048); + }); + + it('drops request headers from end if response fully dropped and still over budget', () => { + // 25 request headers each with name(3) + value(100) = 103 bytes + // 25 * 103 = 2575 > 2048 + const req: Record = {}; + for (let i = 0; i < 25; i++) { + req[`h${String(i).padStart(1, '0')}`] = 'y'.repeat(100); + } + const result = enforceSizeLimits(req, undefined); + const totalBytes = computeTotalBytes( + result.requestHeaders, + result.responseHeaders + ); + expect(totalBytes).toBeLessThanOrEqual(2048); + // Some request headers should be dropped + expect(Object.keys(result.requestHeaders!).length).toBeLessThan(25); + }); + + it('returns undefined for response when all response headers dropped for budget', () => { + // 19 request headers: name(4) + value(100) = 104 each = 1976 + // 1 response header: name(4) + value(100) = 104 -> total 2080 > 2048 + const req: Record = {}; + for (let i = 0; i < 19; i++) { + req[`rq${String(i).padStart(2, '0')}`] = 'x'.repeat(100); + } + const res: Record = { + rs00: 'x'.repeat(100) + }; + const result = enforceSizeLimits(req, res); + expect(result.requestHeaders).toBeDefined(); + expect(result.responseHeaders).toBeUndefined(); + }); + + it('returns undefined for request when all headers dropped for budget', () => { + // Build a scenario where request has one giant-name header + // After truncation, still over budget -> request dropped entirely + const req: Record = {}; + // Create headers with long names that blow the budget + for (let i = 0; i < 30; i++) { + req['h'.repeat(70) + i] = 'v'.repeat(128); + } + const result = enforceSizeLimits(req, undefined); + const totalBytes = computeTotalBytes( + result.requestHeaders, + result.responseHeaders + ); + expect(totalBytes).toBeLessThanOrEqual(2048); + }); + }); + + describe('combined processing order', () => { + it('truncates values before counting bytes for budget', () => { + // 10 headers with 500-char values -> truncated to 128 each + // After truncation: name(2) + value(128) = 130 each -> 1300 total < 2048 + const req: Record = {}; + for (let i = 0; i < 10; i++) { + req[`h${i}`] = 'z'.repeat(500); + } + const result = enforceSizeLimits(req, undefined); + expect(Object.keys(result.requestHeaders!).length).toBe(10); + // All values truncated to 128 + for (const value of Object.values(result.requestHeaders!)) { + expect(value.length).toBeLessThanOrEqual(128); + } + }); + }); +}); + +/** Helper: compute total bytes for all headers */ +function computeTotalBytes( + reqHeaders: Record | undefined, + resHeaders: Record | undefined +): number { + let total = 0; + if (reqHeaders) { + for (const [name, value] of Object.entries(reqHeaders)) { + total += name.length + value.length; + } + } + if (resHeaders) { + for (const [name, value] of Object.entries(resHeaders)) { + total += name.length + value.length; + } + } + return total; +} diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/isHeaderAllowed.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/isHeaderAllowed.test.ts new file mode 100644 index 000000000..bff0dbe88 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/isHeaderAllowed.test.ts @@ -0,0 +1,35 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { isHeaderAllowed } from '../isHeaderAllowed'; + +describe('isHeaderAllowed', () => { + it('blocks sensitive headers', () => { + expect(isHeaderAllowed('authorization')).toBe(false); + }); + + it('blocks tracing headers', () => { + expect(isHeaderAllowed('x-datadog-trace-id')).toBe(false); + }); + + it('allows normal headers', () => { + expect(isHeaderAllowed('content-type')).toBe(true); + }); + + describe('case-insensitive', () => { + it('blocks Authorization (capitalized)', () => { + expect(isHeaderAllowed('Authorization')).toBe(false); + }); + + it('blocks X-B3-TraceId (mixed case tracing header)', () => { + expect(isHeaderAllowed('X-B3-TraceId')).toBe(false); + }); + }); + + it('allows headers that pass both checks', () => { + expect(isHeaderAllowed('etag')).toBe(true); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/parseResponseHeaders.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/parseResponseHeaders.test.ts new file mode 100644 index 000000000..994025bf1 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/parseResponseHeaders.test.ts @@ -0,0 +1,105 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { parseResponseHeaders } from '../parseResponseHeaders'; + +describe('parseResponseHeaders', () => { + it('parses standard CRLF-delimited headers', () => { + const raw = 'content-type: text/html\r\ncache-control: no-cache\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'content-type': 'text/html', + 'cache-control': 'no-cache' + }); + }); + + it('returns empty object for null input', () => { + expect(parseResponseHeaders(null)).toEqual({}); + }); + + it('returns empty object for undefined input', () => { + expect(parseResponseHeaders(undefined)).toEqual({}); + }); + + it('returns empty object for empty string input', () => { + expect(parseResponseHeaders('')).toEqual({}); + }); + + it('uses last-value-wins for duplicate header names', () => { + const raw = 'set-cookie: a=1\r\nset-cookie: b=2\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'set-cookie': 'b=2' + }); + }); + + it('normalizes header names to lowercase', () => { + const raw = 'Content-Type: text/html\r\nCache-Control: no-cache\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'content-type': 'text/html', + 'cache-control': 'no-cache' + }); + }); + + it('trims whitespace from header values (HTTP OWS)', () => { + const raw = 'content-type: text/html \r\ncache-control:no-cache\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'content-type': 'text/html', + 'cache-control': 'no-cache' + }); + }); + + it('skips malformed lines with no colon', () => { + const raw = + 'content-type: text/html\r\ngarbage line\r\ncache-control: no-cache\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'content-type': 'text/html', + 'cache-control': 'no-cache' + }); + }); + + it('skips lines where colon is the first character (empty name)', () => { + const raw = ': some-value\r\ncontent-type: text/html\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'content-type': 'text/html' + }); + }); + + it('handles bare LF without CR (robustness)', () => { + const raw = 'content-type: text/html\ncache-control: no-cache\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'content-type': 'text/html', + 'cache-control': 'no-cache' + }); + }); + + it('preserves colons in header values (only first colon is separator)', () => { + const raw = 'location: https://example.com:8080/path\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + location: 'https://example.com:8080/path' + }); + }); + + it('handles empty header values', () => { + const raw = 'x-custom: \r\ncontent-type: text/html\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'x-custom': '', + 'content-type': 'text/html' + }); + }); + + it('does not produce empty key from trailing CRLF', () => { + const raw = 'content-type: text/html\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'content-type': 'text/html' + }); + }); + + it('trims whitespace from header names before lowercasing', () => { + const raw = ' Content-Type : text/html\r\n'; + expect(parseResponseHeaders(raw)).toEqual({ + 'content-type': 'text/html' + }); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/sensitiveHeaderBlocklist.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/sensitiveHeaderBlocklist.test.ts new file mode 100644 index 000000000..d7a8a66d0 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/sensitiveHeaderBlocklist.test.ts @@ -0,0 +1,109 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { isSensitiveHeader } from '../sensitiveHeaderBlocklist'; + +describe('isSensitiveHeader', () => { + describe('MUST BLOCK sensitive headers (returns true)', () => { + const sensitiveHeaders = [ + // Standard auth + 'authorization', + 'Authorization', + 'AUTHORIZATION', + 'proxy-authorization', + 'Proxy-Authorization', + // Cookies + 'cookie', + 'Cookie', + 'COOKIE', + 'set-cookie', + 'Set-Cookie', + // Token patterns + 'x-access-token', + 'X-Access-Token', + 'x-auth-token', + 'x-csrf-token', + 'x-xsrf-token', + 'x-amz-security-token', + // Secrets and passwords + 'secret', + 'x-secret-key', + 'password', + 'x-password', + // Credentials + 'credential', + 'grpc-credential', + 'x-amz-credential', + // Bearer + 'bearer', + // API/secret/access/app key variants + 'api-key', + 'apikey', + 'api_key', + 'api.key', + 'secret-key', + 'secretkey', + 'access-key', + 'accesskey', + 'app-key', + 'appkey', + // Forwarding / IP headers + 'forwarded', + 'x-forwarded-for', + 'x-real-ip', + 'x-connecting-ip', + 'cf-connecting-ip', + 'x-client-ip', + 'true-client-ip', + // gRPC sensitive metadata + 'grpc-metadata-authorization', + 'grpc-metadata-cookie' + ]; + + it.each(sensitiveHeaders)('blocks "%s"', (headerName: string) => { + expect(isSensitiveHeader(headerName)).toBe(true); + }); + }); + + describe('MUST ALLOW safe headers (returns false)', () => { + const safeHeaders = [ + 'content-type', + 'Content-Type', + 'cache-control', + 'Cache-Control', + 'etag', + 'x-request-id', + 'x-correlation-id', + 'x-custom-header', + 'accept', + 'accept-encoding', + 'accept-language', + 'content-length', + 'vary', + 'server-timing', + 'x-cache', + 'age', + 'expires', + 'content-encoding' + ]; + + it.each(safeHeaders)('allows "%s"', (headerName: string) => { + expect(isSensitiveHeader(headerName)).toBe(false); + }); + }); + + describe('case-insensitive matching', () => { + it('blocks both Authorization and authorization', () => { + expect(isSensitiveHeader('Authorization')).toBe(true); + expect(isSensitiveHeader('authorization')).toBe(true); + }); + + it('allows both Cache-Control and cache-control', () => { + expect(isSensitiveHeader('Cache-Control')).toBe(false); + expect(isSensitiveHeader('cache-control')).toBe(false); + }); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/tracingHeaderExclusion.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/tracingHeaderExclusion.test.ts new file mode 100644 index 000000000..dfa09e5ab --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/tracingHeaderExclusion.test.ts @@ -0,0 +1,58 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { isTracingHeader } from '../tracingHeaderExclusion'; + +describe('isTracingHeader', () => { + describe('MUST EXCLUDE all 12 tracing headers (returns true)', () => { + const tracingHeaders = [ + 'x-datadog-sampling-priority', + 'x-datadog-origin', + 'x-datadog-trace-id', + 'x-datadog-parent-id', + 'x-datadog-tags', + 'traceparent', + 'tracestate', + 'baggage', + 'b3', + 'x-b3-traceid', + 'x-b3-spanid', + 'x-b3-sampled' + ]; + + it.each(tracingHeaders)('excludes "%s"', (headerName: string) => { + expect(isTracingHeader(headerName)).toBe(true); + }); + }); + + describe('case-insensitive matching', () => { + it('excludes X-Datadog-Trace-Id (mixed case)', () => { + expect(isTracingHeader('X-Datadog-Trace-Id')).toBe(true); + }); + + it('excludes Traceparent (capitalized)', () => { + expect(isTracingHeader('Traceparent')).toBe(true); + }); + + it('excludes X-B3-TraceId (original constant casing)', () => { + expect(isTracingHeader('X-B3-TraceId')).toBe(true); + }); + }); + + describe('MUST ALLOW non-tracing headers (returns false)', () => { + it('allows content-type', () => { + expect(isTracingHeader('content-type')).toBe(false); + }); + + it('allows x-custom-header', () => { + expect(isTracingHeader('x-custom-header')).toBe(false); + }); + + it('allows x-datadog-customer-id (not a tracing header - explicit list, not prefix match)', () => { + expect(isTracingHeader('x-datadog-customer-id')).toBe(false); + }); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts new file mode 100644 index 000000000..b1447405c --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts @@ -0,0 +1,328 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { isHeaderAllowed } from './isHeaderAllowed'; +import { parseResponseHeaders } from './parseResponseHeaders'; +import type { + CompiledHeaderCaptureConfig, + CompiledHeaderCaptureRule +} from './types'; + +/** + * Default response header names captured in 'defaults' mode. + * Lowercased for direct comparison with parseResponseHeaders output. + */ +export const DEFAULT_RESPONSE_HEADERS: Set = new Set([ + 'cache-control', + 'etag', + 'age', + 'expires', + 'content-type', + 'content-encoding', + 'content-length', + 'vary', + 'server-timing', + 'x-cache' +]); + +/** + * Default request header names captured in 'defaults' mode. + * Lowercased for direct comparison with accumulated header names. + */ +export const DEFAULT_REQUEST_HEADERS: Set = new Set([ + 'cache-control', + 'content-type' +]); + +/** + * Canonical RFC Title-Case casing for default response headers. + * Static map -- explicit lookup, no auto-capitalization. + * Key: lowercased name, Value: Title-Case name. + */ +export const CANONICAL_RESPONSE_HEADERS: Map = new Map([ + ['cache-control', 'Cache-Control'], + ['etag', 'ETag'], + ['age', 'Age'], + ['expires', 'Expires'], + ['content-type', 'Content-Type'], + ['content-encoding', 'Content-Encoding'], + ['content-length', 'Content-Length'], + ['vary', 'Vary'], + ['server-timing', 'Server-Timing'], + ['x-cache', 'X-Cache'] +]); + +/** + * Canonical RFC Title-Case casing for default request headers. + * Key: lowercased name, Value: Title-Case name. + */ +export const CANONICAL_REQUEST_HEADERS: Map = new Map([ + ['cache-control', 'Cache-Control'], + ['content-type', 'Content-Type'] +]); + +/** + * Filters a header record to only entries in the allowed set, applying + * casing from the provided casing map. + * + * Output key resolution: + * - If the input key has non-lowered casing (e.g. from setRequestHeader), use it as-is + * - Otherwise, look up the casing map for config-provided casing + * - Falls back to lowered name if neither provides casing + * + * This handles both directions: + * - Response headers: input keys are lowered (from parseResponseHeaders), so casing map provides output casing + * - Request headers: input keys preserve setRequestHeader casing, which takes priority + * + * Returns undefined if no entries survive filtering (locked decision: no empty objects). + * + * @internal + */ +const filterWithCasing = ( + headers: Record, + allowedSet: Set, + casingMap: Map +): Record | undefined => { + const filtered: Record = {}; + let hasEntries = false; + + for (const [name, value] of Object.entries(headers)) { + const lowered = name.toLowerCase(); + if (allowedSet.has(lowered)) { + // Use original key casing if different from lowered (request headers), + // otherwise fall back to casing map, otherwise use lowered + const outputKey = + name !== lowered + ? name // original casing from accumulator + : casingMap.get(lowered) ?? lowered; + filtered[outputKey] = value; + hasEntries = true; + } + } + + return hasEntries ? filtered : undefined; +}; + +/** + * Loops over compiled rules, finds all that match the given URL, + * and merges their header name sets additively (union). + * + * Scoped-replaces-catch-all: if any rule with isScoped=true matches + * the URL, all catch-all (isScoped=false) rules are ignored. + * Multiple scoped rules matching the same URL merge additively. + * + * @param rules - The compiled rule array. + * @param url - The request URL to match against. + * @param field - Which header set to merge ('requestHeaderNames' or 'responseHeaderNames'). + * @returns The merged Set of header names, or an empty Set if no rules match. + */ +const mergeMatchingHeaderNames = ( + rules: CompiledHeaderCaptureRule[], + url: string, + field: 'requestHeaderNames' | 'responseHeaderNames' +): Set => { + const scopedMatches: Set = new Set(); + const catchAllMatches: Set = new Set(); + let hasScopedMatch = false; + + for (const rule of rules) { + if (rule.urlRegex.test(url)) { + if (rule.isScoped) { + hasScopedMatch = true; + for (const name of rule[field]) { + scopedMatches.add(name); + } + } else { + for (const name of rule[field]) { + catchAllMatches.add(name); + } + } + } + } + + return hasScopedMatch ? scopedMatches : catchAllMatches; +}; + +/** + * Merges casing maps from all matching rules into a single Map. + * Uses the same scoped-replaces-catch-all logic as mergeMatchingHeaderNames. + * + * Casing precedence: + * - Scoped rules: first-declared wins (among custom rules, first casing is kept) + * - Catch-all rules: last-declared wins (custom rules override defaults' casing + * since defaults is typically declared first in the config array) + * + * @param rules - The compiled rule array. + * @param url - The request URL to match against. + * @param field - Which casing map to merge ('requestHeaderCasing' or 'responseHeaderCasing'). + * @returns The merged casing Map, or an empty Map if no rules match. + */ +const mergeCasingMaps = ( + rules: CompiledHeaderCaptureRule[], + url: string, + field: 'requestHeaderCasing' | 'responseHeaderCasing' +): Map => { + const scopedMap = new Map(); + const catchAllMap = new Map(); + let hasScopedMatch = false; + + for (const rule of rules) { + if (rule.urlRegex.test(url)) { + if (rule.isScoped) { + hasScopedMatch = true; + // First-declared wins for scoped rules + for (const [lowered, original] of rule[field]) { + if (!scopedMap.has(lowered)) { + scopedMap.set(lowered, original); + } + } + } else { + // Last-declared wins for catch-all rules + // (ensures custom rules override defaults' casing) + for (const [lowered, original] of rule[field]) { + catchAllMap.set(lowered, original); + } + } + } + } + + return hasScopedMatch ? scopedMap : catchAllMap; +}; + +/** + * Accumulates a single request header into the capture store. + * Applies security filtering at capture time (locked decision: sensitive + * headers never stored in memory). + * + * Mutates the accumulator in place for performance — no allocation per call. + * Last-value-wins semantics for duplicate header names. + * + * @param accumulator - Mutable record to store captured headers. + * @param headerName - The header name as provided by setRequestHeader (any case). + * @param headerValue - The header value string. + */ +export const accumulateRequestHeader = ( + accumulator: Record, + headerName: string, + headerValue: string +): void => { + const lowered = headerName.toLowerCase(); + if (isHeaderAllowed(lowered)) { + // Remove any existing entry with different casing for same header + // (last-value-wins: both value and casing from latest call) + for (const existing of Object.keys(accumulator)) { + if (existing.toLowerCase() === lowered) { + delete accumulator[existing]; + break; + } + } + accumulator[headerName] = headerValue; + } +}; + +/** + * Captures response headers from a raw getAllResponseHeaders() string. + * Applies security filtering first (defense in depth), then filters by + * the union of all matching compiled rules' response header sets. + * + * Returns undefined if: + * - Config is null (disabled) + * - rawHeaders is null/undefined/empty + * - No headers survive security filtering + * - No compiled rules match the URL + * - No headers survive rule-based filtering + * + * (Locked decision: no empty objects — undefined or absent, not `{}`.) + * + * @param rawHeaders - The raw CRLF string from getAllResponseHeaders(). + * @param url - The full request URL for rule matching. + * @param config - The compiled header capture configuration. + * @returns Filtered response headers, or undefined. + */ +export const captureResponseHeaders = ( + rawHeaders: string | null | undefined, + url: string, + config: CompiledHeaderCaptureConfig +): Record | undefined => { + if (config === null) { + return undefined; + } + + const allHeaders = parseResponseHeaders(rawHeaders); + if (Object.keys(allHeaders).length === 0) { + return undefined; + } + + // Security filter first — defense in depth + const securityFiltered: Record = {}; + let hasSecurityFiltered = false; + + for (const [name, value] of Object.entries(allHeaders)) { + if (isHeaderAllowed(name)) { + securityFiltered[name] = value; + hasSecurityFiltered = true; + } + } + + if (!hasSecurityFiltered) { + return undefined; + } + + // Merge all matching rules' response header sets (union) + const allowedHeaders = mergeMatchingHeaderNames( + config, + url, + 'responseHeaderNames' + ); + + if (allowedHeaders.size === 0) { + return undefined; + } + + const casingMap = mergeCasingMaps(config, url, 'responseHeaderCasing'); + return filterWithCasing(securityFiltered, allowedHeaders, casingMap); +}; + +/** + * Filters accumulated request headers by compiled rule matching. + * + * Called at request completion (DONE state) when the URL is final. + * Request headers are accumulated during setRequestHeader calls and + * filtered here to the union of all matching rules' request header sets. + * + * Returns undefined if: + * - Config is null (disabled) + * - No compiled rules match the URL + * - No headers survive rule-based filtering + * + * @param headers - The accumulated request headers (already security-filtered). + * @param url - The final request URL for rule matching. + * @param config - The compiled header capture configuration. + * @returns Filtered request headers, or undefined. + */ +export const filterRequestHeadersByMode = ( + headers: Record, + url: string, + config: CompiledHeaderCaptureConfig +): Record | undefined => { + if (config === null) { + return undefined; + } + + // Merge all matching rules' request header sets (union) + const allowedHeaders = mergeMatchingHeaderNames( + config, + url, + 'requestHeaderNames' + ); + + if (allowedHeaders.size === 0) { + return undefined; + } + + const casingMap = mergeCasingMaps(config, url, 'requestHeaderCasing'); + return filterWithCasing(headers, allowedHeaders, casingMap); +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts new file mode 100644 index 000000000..f0c92d2c3 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts @@ -0,0 +1,300 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { InternalLog } from '../../../../InternalLog'; +import type { HeaderCaptureRule } from '../../../../config/features/RumConfiguration.type'; +import { SdkVerbosity } from '../../../../config/types/SdkVerbosity'; +import { escapeRegExp } from '../distributedTracing/firstPartyHosts'; + +import { + DEFAULT_REQUEST_HEADERS, + DEFAULT_RESPONSE_HEADERS, + CANONICAL_REQUEST_HEADERS, + CANONICAL_RESPONSE_HEADERS +} from './captureHeaders'; +import type { + CompiledHeaderCaptureConfig, + CompiledHeaderCaptureRule +} from './types'; + +/** + * Builds a URL-matching RegExp from a user-supplied `match` string. + * + * The regex is applied to full URL strings (e.g. "https://api.example.com/v2/users"). + * Pattern: `^https?://(.*\.)*${escapedHost}(:\d+)?${pathSuffix}` + * where pathSuffix anchors at end-of-host when no path is given, or matches the path prefix. + * + * Returns null if the pattern is invalid (e.g. contains unescaped regex metacharacters that + * result in an invalid expression). + */ +const buildUrlMatchRegex = (match: string): RegExp | null => { + if (match === '*') { + return /.*/; + } + + // Split on first '/' to separate hostname from optional path prefix + const slashIndex = match.indexOf('/'); + const hostname = slashIndex === -1 ? match : match.slice(0, slashIndex); + const pathPrefix = slashIndex === -1 ? '' : match.slice(slashIndex); + + // Validate that the raw hostname is a valid literal string (no unmatched regex metacharacters + // like `[` without closing `]`). We do this by attempting to compile the raw hostname as + // a regex fragment. If it throws, the match string is invalid — return null before escaping. + try { + // eslint-disable-next-line no-new + new RegExp(hostname); + } catch (_e) { + return null; + } + + const escapedHost = escapeRegExp(hostname); + // If a path prefix is given, escape it and match as prefix. + // If hostname-only, require end-of-host (port or end of authority). + const pathSuffix = pathPrefix ? escapeRegExp(pathPrefix) : '(/|$)'; + + try { + // Regex matches full URL strings: scheme + optional subdomains + host + optional port + path + const regex = new RegExp( + `^https?://(.*\\.)*${escapedHost}(:\\d+)?${pathSuffix}`, + 'i' + ); + // Validate the final regex is well-formed + regex.test('validation_probe'); + return regex; + } catch (_e) { + return null; + } +}; + +/** + * Result from building a forURLs regex, carrying both the compiled regex + * and whether it represents a scoped (specific URL patterns) or catch-all match. + */ +type ForURLsResult = { regex: RegExp; isScoped: boolean } | null | 'skip'; + +/** + * Builds a combined URL-matching RegExp from an array of `forURLs` patterns. + * + * Returns an object with `regex` and `isScoped`, or `null` (all invalid), or `'skip'` (empty array). + * - `undefined` or `['*']` -> catch-all, isScoped=false + * - `[]` -> 'skip' + * - Specific patterns -> combined regex, isScoped=true + */ +const buildForURLsRegex = (forURLs: string[] | undefined): ForURLsResult => { + // Omitting forURLs = match all URLs (catch-all) + if (forURLs === undefined) { + return { regex: /.*/, isScoped: false }; + } + + // Empty array = no-op rule, skip with warning + if (forURLs.length === 0) { + return 'skip'; + } + + // ['*'] is equivalent to omitting forURLs (catch-all) + if (forURLs.length === 1 && forURLs[0] === '*') { + return { regex: /.*/, isScoped: false }; + } + + // Specific patterns = scoped + const patterns: string[] = []; + for (const pattern of forURLs) { + const regex = buildUrlMatchRegex(pattern); + if (regex === null) { + InternalLog.log( + `[DatadogRUM] headerCaptureRules: Skipping invalid forURLs pattern "${pattern}".`, + SdkVerbosity.WARN + ); + continue; + } + // Extract the source from the compiled regex to combine + patterns.push(regex.source); + } + + if (patterns.length === 0) { + return null; + } + + try { + return { regex: new RegExp(patterns.join('|'), 'i'), isScoped: true }; + } catch (_e) { + return null; + } +}; + +/** + * Compiles an array of composable `HeaderCaptureRule` entries into compiled rules. + * Each rule variant type produces a different set of request/response header names. + * Each compiled rule carries an `isScoped` flag from its forURLs resolution. + */ +const compileRules = ( + rules: HeaderCaptureRule[] +): CompiledHeaderCaptureRule[] => { + const compiled: CompiledHeaderCaptureRule[] = []; + + for (const rule of rules) { + // Build URL regex from forURLs + const urlResult = buildForURLsRegex(rule.forURLs); + + if (urlResult === 'skip') { + InternalLog.log( + '[DatadogRUM] headerCaptureRules: Skipping rule with empty forURLs array (no-op).', + SdkVerbosity.WARN + ); + continue; + } + + if (urlResult === null) { + InternalLog.log( + '[DatadogRUM] headerCaptureRules: Skipping rule — all forURLs patterns are invalid.', + SdkVerbosity.WARN + ); + continue; + } + + const { regex: urlRegex, isScoped } = urlResult; + + switch (rule.type) { + case 'defaults': + compiled.push({ + urlRegex, + requestHeaderNames: new Set(DEFAULT_REQUEST_HEADERS), + responseHeaderNames: new Set(DEFAULT_RESPONSE_HEADERS), + isScoped, + requestHeaderCasing: new Map(CANONICAL_REQUEST_HEADERS), + responseHeaderCasing: new Map(CANONICAL_RESPONSE_HEADERS) + }); + break; + + case 'matchHeaders': + compiled.push({ + urlRegex, + requestHeaderNames: new Set( + rule.headers.map(h => h.toLowerCase()) + ), + responseHeaderNames: new Set( + rule.headers.map(h => h.toLowerCase()) + ), + isScoped, + requestHeaderCasing: new Map( + rule.headers.map( + h => [h.toLowerCase(), h] as [string, string] + ) + ), + responseHeaderCasing: new Map( + rule.headers.map( + h => [h.toLowerCase(), h] as [string, string] + ) + ) + }); + break; + + case 'matchRequestHeaders': + compiled.push({ + urlRegex, + requestHeaderNames: new Set( + rule.headers.map(h => h.toLowerCase()) + ), + responseHeaderNames: new Set(), + isScoped, + requestHeaderCasing: new Map( + rule.headers.map( + h => [h.toLowerCase(), h] as [string, string] + ) + ), + responseHeaderCasing: new Map() + }); + break; + + case 'matchResponseHeaders': + compiled.push({ + urlRegex, + requestHeaderNames: new Set(), + responseHeaderNames: new Set( + rule.headers.map(h => h.toLowerCase()) + ), + isScoped, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map( + rule.headers.map( + h => [h.toLowerCase(), h] as [string, string] + ) + ) + }); + break; + + default: + InternalLog.log( + `[DatadogRUM] headerCaptureRules: Skipping rule with unknown type "${ + (rule as { type: string }).type + }".`, + SdkVerbosity.WARN + ); + break; + } + } + + return compiled; +}; + +/** + * Converts a user-supplied header capture config into a runtime-efficient + * `CompiledHeaderCaptureConfig` (which is `CompiledHeaderCaptureRule[] | null`). + * + * Runs once at SDK initialization. Produces pre-built `RegExp` objects and `Set` + * lookups so that per-request header matching is O(1) with no config traversal. + * + * Input: `'defaults' | HeaderCaptureRule[] | undefined` + * - `undefined` (omitted) = disabled, returns `null` + * - `'defaults'` = string shortcut, returns single-element array with default headers and catch-all regex + * - `HeaderCaptureRule[]` = composable array of rules, compiled into `CompiledHeaderCaptureRule[]` + * - `[]` (empty array) = logs WARN, returns `null` + * - Invalid value = logs WARN, returns `null` + */ +export const compileHeaderCaptureConfig = ( + config: 'defaults' | HeaderCaptureRule[] | undefined +): CompiledHeaderCaptureConfig => { + if (config === undefined) { + return null; + } + + if (config === 'defaults') { + return [ + { + requestHeaderNames: new Set(DEFAULT_REQUEST_HEADERS), + responseHeaderNames: new Set(DEFAULT_RESPONSE_HEADERS), + urlRegex: /.*/, + isScoped: false, + requestHeaderCasing: new Map(CANONICAL_REQUEST_HEADERS), + responseHeaderCasing: new Map(CANONICAL_RESPONSE_HEADERS) + } + ]; + } + + if (Array.isArray(config)) { + if (config.length === 0) { + InternalLog.log( + '[DatadogRUM] headerCaptureRules is empty, no headers will be captured.', + SdkVerbosity.WARN + ); + return null; + } + + const compiled = compileRules(config); + if (compiled.length === 0) { + return null; + } + return compiled; + } + + InternalLog.log( + `[DatadogRUM] headerCaptureRules: Unrecognized value "${JSON.stringify( + config + )}" — defaulting to disabled.`, + SdkVerbosity.WARN + ); + return null; +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts new file mode 100644 index 000000000..ad477ed35 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts @@ -0,0 +1,131 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/** Maximum byte length for any single header value. */ +export const MAX_HEADER_VALUE_BYTES = 128; + +/** Maximum combined header count (request + response). */ +export const MAX_HEADER_COUNT = 100; + +/** Maximum total bytes across all header names and values. */ +export const MAX_TOTAL_BYTES = 2048; + +/** + * Converts a non-empty record to undefined-or-record. + * Locked decision: no empty objects -- undefined or absent, not `{}`. + */ +const toUndefinedIfEmpty = ( + record: Record +): Record | undefined => { + return Object.keys(record).length > 0 ? record : undefined; +}; + +/** + * Enforces per-value truncation, header count cap, and total size budget + * on captured header records. + * + * Processing order: + * 1. Short-circuit if both inputs undefined + * 2. Truncate each value to MAX_HEADER_VALUE_BYTES + * 3. Cap combined count to MAX_HEADER_COUNT (request priority) + * 4. Drop headers from end until total bytes <= MAX_TOTAL_BYTES + * 5. Return undefined (not {}) for empty records + * + * Pure function -- no side effects, no logging. + */ +export const enforceSizeLimits = ( + requestHeaders: Record | undefined, + responseHeaders: Record | undefined +): { + requestHeaders: Record | undefined; + responseHeaders: Record | undefined; +} => { + // Step 1: Short-circuit + if (requestHeaders === undefined && responseHeaders === undefined) { + return { requestHeaders: undefined, responseHeaders: undefined }; + } + + // Step 2: Truncate values + const reqEntries = requestHeaders + ? Object.entries(requestHeaders).map(([name, value]): [ + string, + string + ] => [ + name, + value.length > MAX_HEADER_VALUE_BYTES + ? value.slice(0, MAX_HEADER_VALUE_BYTES) + : value + ]) + : []; + + const resEntries = responseHeaders + ? Object.entries(responseHeaders).map(([name, value]): [ + string, + string + ] => [ + name, + value.length > MAX_HEADER_VALUE_BYTES + ? value.slice(0, MAX_HEADER_VALUE_BYTES) + : value + ]) + : []; + + // Step 3: Count cap -- request headers take priority + const totalCount = reqEntries.length + resEntries.length; + let cappedReqEntries = reqEntries; + let cappedResEntries = resEntries; + + if (totalCount > MAX_HEADER_COUNT) { + const reqSlots = Math.min(reqEntries.length, MAX_HEADER_COUNT); + cappedReqEntries = reqEntries.slice(0, reqSlots); + const remainingSlots = MAX_HEADER_COUNT - cappedReqEntries.length; + cappedResEntries = resEntries.slice(0, remainingSlots); + } + + // Step 4: Total size budget -- drop from end (response first, then request) + let totalBytes = 0; + for (const [name, value] of cappedReqEntries) { + totalBytes += name.length + value.length; + } + for (const [name, value] of cappedResEntries) { + totalBytes += name.length + value.length; + } + + if (totalBytes > MAX_TOTAL_BYTES) { + // Drop response headers from end first + while (cappedResEntries.length > 0 && totalBytes > MAX_TOTAL_BYTES) { + const [name, value] = cappedResEntries.pop()!; + totalBytes -= name.length + value.length; + } + // Then drop request headers from end + while (cappedReqEntries.length > 0 && totalBytes > MAX_TOTAL_BYTES) { + const [name, value] = cappedReqEntries.pop()!; + totalBytes -= name.length + value.length; + } + } + + // Step 5: Build result records + const reqResult: Record = {}; + for (const [name, value] of cappedReqEntries) { + reqResult[name] = value; + } + + const resResult: Record = {}; + for (const [name, value] of cappedResEntries) { + resResult[name] = value; + } + + return { + requestHeaders: + requestHeaders !== undefined + ? toUndefinedIfEmpty(reqResult) + : undefined, + responseHeaders: + responseHeaders !== undefined + ? toUndefinedIfEmpty(resResult) + : undefined + }; +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/isHeaderAllowed.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/isHeaderAllowed.ts new file mode 100644 index 000000000..186066d69 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/isHeaderAllowed.ts @@ -0,0 +1,29 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { isSensitiveHeader } from './sensitiveHeaderBlocklist'; +import { isTracingHeader } from './tracingHeaderExclusion'; + +/** + * Composed check: returns true if a header is allowed for capture. + * A header is allowed only if it is NOT sensitive AND NOT a tracing header. + * + * This is the single entry point for Phase 3 XHR integration to call for + * every header considered for capture. + * + * @param headerName - The header name to check (case-insensitive). + * @returns `true` if the header may be captured, `false` if blocked. + */ +export const isHeaderAllowed = (headerName: string): boolean => { + const lowered = headerName.toLowerCase(); + if (isSensitiveHeader(lowered)) { + return false; + } + if (isTracingHeader(lowered)) { + return false; + } + return true; +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/parseResponseHeaders.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/parseResponseHeaders.ts new file mode 100644 index 000000000..dee4427ae --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/parseResponseHeaders.ts @@ -0,0 +1,57 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/** + * Parses a CRLF-delimited `getAllResponseHeaders()` string into a + * `Record` of lowercase header names to trimmed values. + * + * Handles all edge cases defensively — SDK stability takes priority over + * strict compliance. Never throws on any input. + * + * - Splits on `\r?\n` to handle both CRLF and bare LF + * - Uses `indexOf(':')` so values containing colons (e.g. URLs) are preserved + * - Header names are trimmed and lowercased; values are trimmed (HTTP OWS) + * - Duplicate names resolve to last-value-wins + * - Malformed lines (no colon, empty name) are silently skipped + */ +export const parseResponseHeaders = ( + rawHeaders: string | null | undefined +): Record => { + if (!rawHeaders) { + return {}; + } + + const result: Record = {}; + const lines = rawHeaders.split(/\r?\n/); + + for (const line of lines) { + // Skip empty lines (trailing delimiter produces empty last element) + if (line === '') { + continue; + } + + const colonIndex = line.indexOf(':'); + + // No colon found, or colon is first character (empty name) — skip + if (colonIndex <= 0) { + continue; + } + + const name = line.slice(0, colonIndex).trim().toLowerCase(); + + // Name is empty after trimming — skip + if (name === '') { + continue; + } + + const value = line.slice(colonIndex + 1).trim(); + + // Last value wins for duplicate header names + result[name] = value; + } + + return result; +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/sensitiveHeaderBlocklist.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/sensitiveHeaderBlocklist.ts new file mode 100644 index 000000000..c45649143 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/sensitiveHeaderBlocklist.ts @@ -0,0 +1,31 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/** + * Compiled regex matching sensitive header name patterns. + * + * Matches headers containing: token, cookie, secret, authorization, password, + * credential, bearer, api/secret/access/app key variants, forwarding/IP headers. + * + * The `/i` flag ensures case-insensitive matching. Compiled once at module load + * time — no per-call allocation. + */ +const SENSITIVE_HEADER_PATTERN = /(?:token|cookie|secret|authorization|password|credential|bearer|(?:api|secret|access|app).?key|forwarded|real.?ip|connecting.?ip|client.?ip)/i; + +/** + * Checks whether a header name matches a known sensitive header pattern. + * Sensitive headers (authorization, cookies, tokens, API keys, etc.) must + * never be captured regardless of configuration mode. + * + * Silent filtering — no debug log for blocked headers (these are expected + * normal behavior, not warnings). + * + * @param headerName - The header name to check (case-insensitive via regex /i flag). + * @returns `true` if the header is sensitive and must be blocked. + */ +export const isSensitiveHeader = (headerName: string): boolean => { + return SENSITIVE_HEADER_PATTERN.test(headerName); +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/tracingHeaderExclusion.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/tracingHeaderExclusion.ts new file mode 100644 index 000000000..62930af4b --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/tracingHeaderExclusion.ts @@ -0,0 +1,59 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +import { + SAMPLING_PRIORITY_HEADER_KEY, + ORIGIN_HEADER_KEY, + TRACE_ID_HEADER_KEY, + PARENT_ID_HEADER_KEY, + TAGS_HEADER_KEY, + TRACECONTEXT_HEADER_KEY, + TRACESTATE_HEADER_KEY, + BAGGAGE_HEADER_KEY, + B3_HEADER_KEY, + B3_MULTI_TRACE_ID_HEADER_KEY, + B3_MULTI_SPAN_ID_HEADER_KEY, + B3_MULTI_SAMPLED_HEADER_KEY +} from '../distributedTracing/headers'; + +/** + * Set of all SDK-injected distributed tracing header names, lowercased. + * + * Built once at module load time from the canonical header constants in + * `distributedTracing/headers.ts`. The B3 multi-headers have mixed case + * (e.g. `X-B3-TraceId`), so all values are lowercased for uniform lookup. + */ +const TRACING_HEADERS: Set = new Set( + [ + SAMPLING_PRIORITY_HEADER_KEY, + ORIGIN_HEADER_KEY, + TRACE_ID_HEADER_KEY, + PARENT_ID_HEADER_KEY, + TAGS_HEADER_KEY, + TRACECONTEXT_HEADER_KEY, + TRACESTATE_HEADER_KEY, + BAGGAGE_HEADER_KEY, + B3_HEADER_KEY, + B3_MULTI_TRACE_ID_HEADER_KEY, + B3_MULTI_SPAN_ID_HEADER_KEY, + B3_MULTI_SAMPLED_HEADER_KEY + ].map(h => h.toLowerCase()) +); + +/** + * Checks whether a header name is an SDK-injected distributed tracing header. + * Tracing headers must never be captured because they are SDK internals, + * not application-level headers. + * + * Uses an explicit list (not prefix matching) — `x-datadog-customer-id` + * would NOT be excluded. + * + * @param headerName - The header name to check (lowercased internally for lookup). + * @returns `true` if the header is a tracing header and must be excluded. + */ +export const isTracingHeader = (headerName: string): boolean => { + return TRACING_HEADERS.has(headerName.toLowerCase()); +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/types.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/types.ts new file mode 100644 index 000000000..5bb8eb9bd --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/types.ts @@ -0,0 +1,33 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/** + * Compiled runtime representation of the header capture configuration. + * Produced once at SDK initialization by `compileHeaderCaptureConfig`. + * + * - `null` means header capture is disabled (undefined input, empty array, or invalid value). + * - `CompiledHeaderCaptureRule[]` contains one or more compiled rules to evaluate per request. + */ +export type CompiledHeaderCaptureConfig = CompiledHeaderCaptureRule[] | null; + +/** + * A single compiled URL-scoped header capture rule. + * Pre-built for O(1) per-request matching — no config traversal at capture time. + */ +export type CompiledHeaderCaptureRule = { + /** Pre-built RegExp for URL matching (full URL string). */ + urlRegex: RegExp; + /** Lowercased request header names. Set for O(1) lookup. */ + requestHeaderNames: Set; + /** Lowercased response header names. Set for O(1) lookup. */ + responseHeaderNames: Set; + /** True if this rule was compiled from specific forURLs patterns (not catch-all). */ + isScoped: boolean; + /** Map from lowercased header name to original/config-provided casing for request headers. */ + requestHeaderCasing: Map; + /** Map from lowercased header name to original/config-provided casing for response headers. */ + responseHeaderCasing: Map; +}; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/ResourceReporter.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/ResourceReporter.ts index f06a6258f..245e2b230 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/ResourceReporter.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/ResourceReporter.ts @@ -52,7 +52,9 @@ const formatResourceStartContext = ( const formatResourceStopContext = ( timings: RUMResource['timings'], - graphqlAttributes: RUMResource['graphqlAttributes'] + graphqlAttributes: RUMResource['graphqlAttributes'], + capturedRequestHeaders?: Record, + capturedResponseHeaders?: Record ): Record => { const attributes: Record = {}; @@ -76,6 +78,13 @@ const formatResourceStopContext = ( } } + if (capturedRequestHeaders !== undefined) { + attributes['_dd.request_headers'] = capturedRequestHeaders; + } + if (capturedResponseHeaders !== undefined) { + attributes['_dd.response_headers'] = capturedResponseHeaders; + } + return attributes; }; @@ -93,7 +102,12 @@ const reportResource = async (resource: RUMResource) => { resource.response.statusCode, resource.request.kind, resource.response.size, - formatResourceStopContext(resource.timings, resource.graphqlAttributes), + formatResourceStopContext( + resource.timings, + resource.graphqlAttributes, + resource.capturedRequestHeaders, + resource.capturedResponseHeaders + ), resource.timings.stopTime, resource.resourceContext ); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/ResourceReporter.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/ResourceReporter.test.ts index a2c147603..9febced26 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/ResourceReporter.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/DatadogRumResource/__tests__/ResourceReporter.test.ts @@ -83,4 +83,124 @@ describe('Resource reporter', () => { expect(DdRum.startResource).not.toHaveBeenCalled(); expect(DdRum.stopResource).not.toHaveBeenCalled(); }); + + describe('captured header attributes in stopResource context', () => { + it('includes _dd.request_headers in stopResource context when captured request headers are present', async () => { + // GIVEN + const resourceReporter = new ResourceReporter([]); + const resource = resourceMockFactory.getCustomResource({ + capturedRequestHeaders: { + 'content-type': 'application/json', + 'cache-control': 'no-cache' + } + }); + + // WHEN + resourceReporter.reportResource(resource); + await flushPromises(); + + // THEN + const stopContext = DdRum.stopResource.mock.calls[0][4]; + expect(stopContext).toEqual( + expect.objectContaining({ + '_dd.request_headers': { + 'content-type': 'application/json', + 'cache-control': 'no-cache' + } + }) + ); + }); + + it('includes _dd.response_headers in stopResource context when captured response headers are present', async () => { + // GIVEN + const resourceReporter = new ResourceReporter([]); + const resource = resourceMockFactory.getCustomResource({ + capturedResponseHeaders: { + etag: '"abc123"', + 'cache-control': 'max-age=3600' + } + }); + + // WHEN + resourceReporter.reportResource(resource); + await flushPromises(); + + // THEN + const stopContext = DdRum.stopResource.mock.calls[0][4]; + expect(stopContext).toEqual( + expect.objectContaining({ + '_dd.response_headers': { + etag: '"abc123"', + 'cache-control': 'max-age=3600' + } + }) + ); + }); + + it('includes both _dd.request_headers and _dd.response_headers when both are present', async () => { + // GIVEN + const resourceReporter = new ResourceReporter([]); + const resource = resourceMockFactory.getCustomResource({ + capturedRequestHeaders: { + 'content-type': 'text/plain' + }, + capturedResponseHeaders: { 'x-cache': 'HIT' } + }); + + // WHEN + resourceReporter.reportResource(resource); + await flushPromises(); + + // THEN + const stopContext = DdRum.stopResource.mock.calls[0][4]; + expect(stopContext).toEqual( + expect.objectContaining({ + '_dd.request_headers': { + 'content-type': 'text/plain' + }, + '_dd.response_headers': { 'x-cache': 'HIT' } + }) + ); + }); + + it('does not include _dd.request_headers or _dd.response_headers when headers are undefined', async () => { + // GIVEN + const resourceReporter = new ResourceReporter([]); + const resource = resourceMockFactory.getBasicResource(); + + // WHEN + resourceReporter.reportResource(resource); + await flushPromises(); + + // THEN + const stopContext = DdRum.stopResource.mock.calls[0][4]; + expect(stopContext).not.toHaveProperty('_dd.request_headers'); + expect(stopContext).not.toHaveProperty('_dd.response_headers'); + }); + + it('does not include _dd.request_headers when only response headers are present', async () => { + // GIVEN + const resourceReporter = new ResourceReporter([]); + const resource = resourceMockFactory.getCustomResource({ + capturedResponseHeaders: { + 'x-request-id': '12345' + } + }); + + // WHEN + resourceReporter.reportResource(resource); + await flushPromises(); + + // THEN + const stopContext = DdRum.stopResource.mock.calls[0][4]; + expect(stopContext).toEqual( + expect.objectContaining({ + '_dd.response_headers': { + 'x-request-id': '12345' + } + }) + ); + expect(stopContext).not.toHaveProperty('_dd.request_headers'); + }); + }); }); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts index 6636944f8..d94922a7e 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts @@ -19,6 +19,13 @@ import { DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER, DATADOG_GRAPH_QL_VARIABLES_HEADER } from '../../graphql/graphqlHeaders'; +import { + accumulateRequestHeader, + captureResponseHeaders, + filterRequestHeadersByMode +} from '../../headerCapture/captureHeaders'; +import { enforceSizeLimits } from '../../headerCapture/enforceSizeLimits'; +import type { CompiledHeaderCaptureConfig } from '../../headerCapture/types'; import { DATADOG_BAGGAGE_HEADER, isDatadogCustomHeader } from '../../headers'; import type { RequestProxyOptions } from '../interfaces/RequestProxy'; import { RequestProxy } from '../interfaces/RequestProxy'; @@ -47,6 +54,9 @@ interface DdRumXhrContext { timer: Timer; tracingAttributes: DdRumResourceTracingAttributes; baggageHeaderEntries: Set; + headerCaptureConfig: CompiledHeaderCaptureConfig; + capturedRequestHeaders: Record | undefined; + capturedResponseHeaders?: Record; } interface XHRProxyProviders { @@ -129,7 +139,11 @@ const proxyOpen = ( userId: getCachedUserId(), accountId: getCachedAccountId() }), - baggageHeaderEntries: new Set() + baggageHeaderEntries: new Set(), + headerCaptureConfig: context.headerCaptureConfig, + capturedRequestHeaders: + context.headerCaptureConfig !== null ? {} : undefined, + capturedResponseHeaders: undefined }; // eslint-disable-next-line prefer-rest-params return originalXhrOpen.apply(this, arguments as any); @@ -179,6 +193,42 @@ const proxyOnReadyStateChange = ( xhrProxy.onreadystatechange = function onreadystatechange() { if (xhrProxy.readyState === xhrType.DONE) { + // Capture response headers (only if capture enabled and not aborted/network-error) + if ( + xhrProxy._datadog_xhr.headerCaptureConfig !== null && + xhrProxy.status !== 0 + ) { + xhrProxy._datadog_xhr.capturedResponseHeaders = captureResponseHeaders( + xhrProxy.getAllResponseHeaders(), + xhrProxy._datadog_xhr.url, + xhrProxy._datadog_xhr.headerCaptureConfig + ); + } + + // Filter accumulated request headers by mode now that URL is final + if (xhrProxy._datadog_xhr.capturedRequestHeaders !== undefined) { + xhrProxy._datadog_xhr.capturedRequestHeaders = filterRequestHeadersByMode( + xhrProxy._datadog_xhr.capturedRequestHeaders, + xhrProxy._datadog_xhr.url, + xhrProxy._datadog_xhr.headerCaptureConfig + ); + } + + // Enforce size limits after security/mode filtering, before reporting + if ( + xhrProxy._datadog_xhr.capturedRequestHeaders !== undefined || + xhrProxy._datadog_xhr.capturedResponseHeaders !== undefined + ) { + const limited = enforceSizeLimits( + xhrProxy._datadog_xhr.capturedRequestHeaders, + xhrProxy._datadog_xhr.capturedResponseHeaders + ); + xhrProxy._datadog_xhr.capturedRequestHeaders = + limited.requestHeaders; + xhrProxy._datadog_xhr.capturedResponseHeaders = + limited.responseHeaders; + } + if (!xhrProxy._datadog_xhr.reported) { reportXhr(xhrProxy, providers.resourceReporter); xhrProxy._datadog_xhr.reported = true; @@ -226,6 +276,8 @@ const reportXhr = async ( ? context.timer.timeAt(RESPONSE_START_LABEL) : undefined }, + capturedRequestHeaders: context.capturedRequestHeaders, + capturedResponseHeaders: context.capturedResponseHeaders, resourceContext: xhrProxy }); }; @@ -269,7 +321,15 @@ const proxySetRequestHeader = (providers: XHRProxyProviders): void => { this._datadog_xhr.baggageHeaderEntries?.add(value); } else { // eslint-disable-next-line prefer-rest-params - return originalXhrSetRequestHeader.apply(this, arguments as any); + originalXhrSetRequestHeader.apply(this, arguments as any); + // Accumulate for header capture (only user-set, non-Datadog headers) + if (this._datadog_xhr?.capturedRequestHeaders !== undefined) { + accumulateRequestHeader( + this._datadog_xhr.capturedRequestHeaders, + header, + value + ); + } } }; }; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts index b82d73127..66fcc1954 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/__tests__/XHRProxy.test.ts @@ -68,6 +68,42 @@ const hexToDecimal = (hex: string): string => { return BigInt(hex, 16).toString(10); }; +const defaultsConfig = [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(['cache-control', 'content-type']), + responseHeaderNames: new Set([ + 'cache-control', + 'etag', + 'age', + 'expires', + 'content-type', + 'content-encoding', + 'content-length', + 'vary', + 'server-timing', + 'x-cache' + ]), + isScoped: false, + requestHeaderCasing: new Map([ + ['cache-control', 'Cache-Control'], + ['content-type', 'Content-Type'] + ]), + responseHeaderCasing: new Map([ + ['cache-control', 'Cache-Control'], + ['etag', 'ETag'], + ['age', 'Age'], + ['expires', 'Expires'], + ['content-type', 'Content-Type'], + ['content-encoding', 'Content-Encoding'], + ['content-length', 'Content-Length'], + ['vary', 'Vary'], + ['server-timing', 'Server-Timing'], + ['x-cache', 'X-Cache'] + ]) + } +]; + beforeEach(() => { DdNativeRum.startResource.mockClear(); DdNativeRum.stopResource.mockClear(); @@ -111,7 +147,8 @@ describe('XHRProxy', () => { const url = 'https://api.example.com:443/v2/user'; xhrProxy.onTrackingStart({ tracingSamplingRate: 100, - firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]) + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null }); // WHEN @@ -148,7 +185,8 @@ describe('XHRProxy', () => { const url = 'https://api.example.com/v2/user'; xhrProxy.onTrackingStart({ tracingSamplingRate: 100, - firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]) + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null }); // WHEN @@ -185,7 +223,8 @@ describe('XHRProxy', () => { const url = 'https://api.example.com/v2/user'; xhrProxy.onTrackingStart({ tracingSamplingRate: 100, - firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]) + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null }); // WHEN @@ -227,7 +266,8 @@ describe('XHRProxy', () => { match: 'api.example.com', propagatorTypes: [PropagatorType.DATADOG] } - ]) + ]), + headerCaptureConfig: null }); // WHEN @@ -260,7 +300,8 @@ describe('XHRProxy', () => { const url = 'https://api.example.com/v2/user'; xhrProxy.onTrackingStart({ tracingSamplingRate: 100, - firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]) + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null }); // WHEN @@ -293,7 +334,8 @@ describe('XHRProxy', () => { match: 'api.example.co', propagatorTypes: [PropagatorType.DATADOG] } - ]) + ]), + headerCaptureConfig: null }); // WHEN @@ -322,7 +364,8 @@ describe('XHRProxy', () => { match: 'example.com', propagatorTypes: [PropagatorType.DATADOG] } - ]) + ]), + headerCaptureConfig: null }); // WHEN @@ -351,7 +394,8 @@ describe('XHRProxy', () => { match: 'api.example.com', propagatorTypes: [PropagatorType.DATADOG] } - ]) + ]), + headerCaptureConfig: null }); // WHEN @@ -381,7 +425,8 @@ describe('XHRProxy', () => { const url = 'https://api.example.com/v2/user'; xhrProxy.onTrackingStart({ tracingSamplingRate: 100, - firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]) + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null }); // WHEN @@ -407,7 +452,8 @@ describe('XHRProxy', () => { match: 'api.example.com', propagatorTypes: [PropagatorType.DATADOG] } - ]) + ]), + headerCaptureConfig: null }); // WHEN @@ -435,7 +481,8 @@ describe('XHRProxy', () => { match: 'api.example.com', propagatorTypes: [PropagatorType.DATADOG] } - ]) + ]), + headerCaptureConfig: null }); // WHEN @@ -467,7 +514,8 @@ describe('XHRProxy', () => { match: 'example.com', propagatorTypes: [PropagatorType.TRACECONTEXT] } - ]) + ]), + headerCaptureConfig: null }); // WHEN @@ -515,7 +563,8 @@ describe('XHRProxy', () => { match: 'example.com', propagatorTypes: [PropagatorType.B3MULTI] } - ]) + ]), + headerCaptureConfig: null }); // WHEN @@ -616,7 +665,8 @@ describe('XHRProxy', () => { match: 'example.com', propagatorTypes: [PropagatorType.B3MULTI] } - ]) + ]), + headerCaptureConfig: null }); // WHEN @@ -702,7 +752,8 @@ describe('XHRProxy', () => { match: 'example.com', propagatorTypes: [PropagatorType.B3MULTI] } - ]) + ]), + headerCaptureConfig: null }); // WHEN @@ -739,7 +790,8 @@ describe('XHRProxy', () => { match: 'example.com', propagatorTypes: [PropagatorType.B3] } - ]) + ]), + headerCaptureConfig: null }); // WHEN @@ -778,7 +830,8 @@ describe('XHRProxy', () => { PropagatorType.B3MULTI ] } - ]) + ]), + headerCaptureConfig: null }); // WHEN @@ -836,7 +889,8 @@ describe('XHRProxy', () => { PropagatorType.B3MULTI ] } - ]) + ]), + headerCaptureConfig: null }); setCachedSessionId('TEST-SESSION-ID'); @@ -879,7 +933,8 @@ describe('XHRProxy', () => { PropagatorType.B3MULTI ] } - ]) + ]), + headerCaptureConfig: null }); setCachedSessionId(undefined as any); @@ -917,7 +972,8 @@ describe('XHRProxy', () => { PropagatorType.B3MULTI ] } - ]) + ]), + headerCaptureConfig: null }); setCachedSessionId('TEST-SESSION-ID'); @@ -955,7 +1011,8 @@ describe('XHRProxy', () => { PropagatorType.B3MULTI ] } - ]) + ]), + headerCaptureConfig: null }); setCachedSessionId('TEST-SESSION-ID'); @@ -999,7 +1056,8 @@ describe('XHRProxy', () => { match: 'api.example.com', propagatorTypes: [PropagatorType.DATADOG] } - ]) + ]), + headerCaptureConfig: null }); // WHEN @@ -1035,7 +1093,8 @@ describe('XHRProxy', () => { const url = 'https://api.example.com/v2/user'; xhrProxy.onTrackingStart({ tracingSamplingRate: 50, - firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]) + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null }); // WHEN @@ -1074,7 +1133,8 @@ describe('XHRProxy', () => { match: 'api.example.com', propagatorTypes: [PropagatorType.DATADOG] } - ]) + ]), + headerCaptureConfig: null }); jest.spyOn(global.Math, 'random').mockReturnValue(0.7); @@ -1111,7 +1171,8 @@ describe('XHRProxy', () => { const url = 'https://api.example.com/v2/user'; xhrProxy.onTrackingStart({ tracingSamplingRate: 100, - firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]) + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null }); jest.doMock('react-native/Libraries/Utilities/Platform', () => ({ @@ -1159,7 +1220,8 @@ describe('XHRProxy', () => { const url = 'https://api.example.com/v2/user'; xhrProxy.onTrackingStart({ tracingSamplingRate: 100, - firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]) + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null }); jest.doMock('react-native/Libraries/Utilities/Platform', () => ({ @@ -1209,7 +1271,8 @@ describe('XHRProxy', () => { const url = 'https://api.example.com/v2/user'; xhrProxy.onTrackingStart({ tracingSamplingRate: 100, - firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]) + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null }); // WHEN @@ -1232,7 +1295,8 @@ describe('XHRProxy', () => { const url = 'https://api.example.com/v2/user'; xhrProxy.onTrackingStart({ tracingSamplingRate: 100, - firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]) + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null }); DdRum.registerResourceEventMapper(event => { (event.context as any)['body'] = JSON.parse( @@ -1476,7 +1540,8 @@ describe('XHRProxy', () => { const url = 'https://api.example.com/graphql'; xhrProxy.onTrackingStart({ tracingSamplingRate: 100, - firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]) + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null }); // WHEN @@ -1519,7 +1584,8 @@ describe('XHRProxy', () => { const url = 'https://api.example.com/graphql'; xhrProxy.onTrackingStart({ tracingSamplingRate: 100, - firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]) + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null }); // WHEN @@ -1557,7 +1623,8 @@ describe('XHRProxy', () => { const url = 'https://api.example.com/graphql'; xhrProxy.onTrackingStart({ tracingSamplingRate: 100, - firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]) + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null }); // WHEN @@ -1596,7 +1663,8 @@ describe('XHRProxy', () => { const url = 'https://api.example.com/graphql'; xhrProxy.onTrackingStart({ tracingSamplingRate: 100, - firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]) + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null }); DdRum.registerResourceEventMapper(event => { if ((event.context as any)['_dd.graphql.variables']) { @@ -1639,4 +1707,424 @@ describe('XHRProxy', () => { ); }); }); + + describe('header capture', () => { + it('disabled mode: does not accumulate request headers', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN + expect( + (xhr as any)._datadog_xhr.capturedRequestHeaders + ).toBeUndefined(); + }); + + it('disabled mode: does not capture response headers', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'content-type: text/html\r\n' + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN + expect( + (xhr as any)._datadog_xhr.capturedResponseHeaders + ).toBeUndefined(); + }); + + it('defaults mode: accumulates and filters allowed request headers', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: defaultsConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.setRequestHeader('X-Custom', 'val'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN — content-type is in defaults, x-custom is not + expect((xhr as any)._datadog_xhr.capturedRequestHeaders).toEqual({ + 'Content-Type': 'application/json' + }); + }); + + it('defaults mode: captures default response headers', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: defaultsConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'content-type: text/html\r\ncache-control: no-cache\r\nx-custom: val\r\n' + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN — content-type, cache-control in defaults; x-custom not + expect((xhr as any)._datadog_xhr.capturedResponseHeaders).toEqual({ + 'Content-Type': 'text/html', + 'Cache-Control': 'no-cache' + }); + }); + + it('defaults mode: skips response capture on aborted request (status 0)', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: defaultsConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.send(); + xhr.getAllResponseHeaders.mockReturnValue( + 'content-type: text/html\r\n' + ); + xhr.abort(); + xhr.complete(0, undefined); + await flushPromises(); + + // THEN — status 0 means response capture is skipped + expect( + (xhr as any)._datadog_xhr.capturedResponseHeaders + ).toBeUndefined(); + }); + + it('custom mode: captures only rule-matched headers', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/data'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: [ + { + urlRegex: /.*example\.com.*/, + requestHeaderNames: new Set(['x-request-id']), + responseHeaderNames: new Set(['etag', 'x-cache']), + isScoped: false, + requestHeaderCasing: new Map([ + ['x-request-id', 'x-request-id'] + ]), + responseHeaderCasing: new Map([ + ['etag', 'etag'], + ['x-cache', 'x-cache'] + ]) + } + ] + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('X-Request-Id', 'abc'); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'etag: abc123\r\ncontent-type: text/html\r\nx-cache: HIT\r\n' + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN — only rule-matched headers appear + expect((xhr as any)._datadog_xhr.capturedRequestHeaders).toEqual({ + 'X-Request-Id': 'abc' + }); + expect((xhr as any)._datadog_xhr.capturedResponseHeaders).toEqual({ + etag: 'abc123', + 'x-cache': 'HIT' + }); + }); + + it('security: sensitive request headers are never accumulated', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: defaultsConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Authorization', 'Bearer token'); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN — authorization blocked by security filter + expect((xhr as any)._datadog_xhr.capturedRequestHeaders).toEqual({ + 'Content-Type': 'application/json' + }); + }); + + it('security: sensitive response headers are filtered out', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(), + responseHeaderNames: new Set([ + 'set-cookie', + 'cache-control' + ]), + isScoped: false, + requestHeaderCasing: new Map(), + responseHeaderCasing: new Map([ + ['set-cookie', 'set-cookie'], + ['cache-control', 'cache-control'] + ]) + } + ] + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'set-cookie: session=abc\r\ncache-control: no-cache\r\n' + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN — set-cookie blocked by isSensitiveHeader; only cache-control appears + expect((xhr as any)._datadog_xhr.capturedResponseHeaders).toEqual({ + 'cache-control': 'no-cache' + }); + }); + }); + + describe('header capture integration', () => { + it('disabled mode: no header attributes in stopResource context', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: null + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.setRequestHeader('Accept', 'text/html'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'content-type: text/html\r\ncache-control: no-cache\r\n' + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN + const stopContext = DdNativeRum.stopResource.mock.calls[0][4]; // 5th arg (index 4) = resource context + expect(stopContext['_dd.request_headers']).toBeUndefined(); + expect(stopContext['_dd.response_headers']).toBeUndefined(); + }); + + it('defaults mode: correct default headers captured, sensitive headers excluded', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: defaultsConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.setRequestHeader('Authorization', 'Bearer secret'); + xhr.setRequestHeader('X-Custom', 'val'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'content-type: text/html\r\ncache-control: no-cache\r\netag: abc\r\nset-cookie: session=xyz\r\nx-custom-resp: val\r\n' + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN + const stopContext = DdNativeRum.stopResource.mock.calls[0][4]; // 5th arg (index 4) = resource context + // Only default, non-sensitive request headers + expect(stopContext['_dd.request_headers']).toEqual({ + 'Content-Type': 'application/json' + }); + // Only default response headers; set-cookie is sensitive, x-custom-resp not in defaults + expect(stopContext['_dd.response_headers']).toEqual({ + 'Content-Type': 'text/html', + 'Cache-Control': 'no-cache', + ETag: 'abc' + }); + }); + + it('custom mode: only rule-matched headers captured', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/data'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: [ + { + urlRegex: /.*/, + requestHeaderNames: new Set(['x-request-id']), + responseHeaderNames: new Set(['x-correlation-id']), + isScoped: false, + requestHeaderCasing: new Map([ + ['x-request-id', 'x-request-id'] + ]), + responseHeaderCasing: new Map([ + ['x-correlation-id', 'x-correlation-id'] + ]) + } + ] + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('X-Request-Id', 'test-123'); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'x-correlation-id: abc123\r\ncontent-type: text/html\r\n' + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN + const stopContext = DdNativeRum.stopResource.mock.calls[0][4]; // 5th arg (index 4) = resource context + // Only the custom rule match + expect(stopContext['_dd.request_headers']).toEqual({ + 'X-Request-Id': 'test-123' + }); + expect(stopContext['_dd.response_headers']).toEqual({ + 'x-correlation-id': 'abc123' + }); + }); + + it('defaults mode: duplicate setRequestHeader uses last value (last-wins)', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: defaultsConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Content-Type', 'text/plain'); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN + const stopContext = DdNativeRum.stopResource.mock.calls[0][4]; // 5th arg (index 4) = resource context + expect(stopContext['_dd.request_headers']).toEqual({ + 'Content-Type': 'application/json' + }); + }); + + it('network failure: resource reported without throwing, no response headers', async () => { + // GIVEN + const method = 'GET'; + const url = 'https://api.example.com/v2/user'; + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: defaultsConfig + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open(method, url); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(); + // Status 0 = network error/abort; XHRProxy skips response header capture by design + xhr.getAllResponseHeaders.mockReturnValue(null); + xhr.complete(0, undefined); + await flushPromises(); + + // THEN + expect(DdNativeRum.stopResource).toHaveBeenCalledTimes(1); + const stopContext = DdNativeRum.stopResource.mock.calls[0][4]; // 5th arg (index 4) = resource context + expect(stopContext['_dd.response_headers']).toBeUndefined(); + // Test completing without throwing is proof no exception occurred + }); + }); }); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RequestProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RequestProxy.ts index 61838d695..7396a0bc4 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RequestProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RequestProxy.ts @@ -5,10 +5,12 @@ */ import type { PropagatorType } from '../../../../types'; +import type { CompiledHeaderCaptureConfig } from '../../headerCapture/types'; export interface RequestProxyOptions { tracingSamplingRate: number; firstPartyHostsRegexMap: RegexMap; + headerCaptureConfig: CompiledHeaderCaptureConfig; } export type RegexMap = { diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RumResource.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RumResource.ts index 15aab8201..def61ab04 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RumResource.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/interfaces/RumResource.ts @@ -26,6 +26,8 @@ export interface RUMResource { responseStartTime?: number; }; resourceContext?: XMLHttpRequest; + capturedRequestHeaders?: Record; + capturedResponseHeaders?: Record; } export type DdRumResourceGraphqlAttributes = { diff --git a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx index 596ab9532..5ab295642 100644 --- a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx +++ b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx @@ -65,7 +65,7 @@ describe('DatadogProvider', () => { expect(receivedConfiguration).toMatchInlineSnapshot(` DdSdkNativeConfiguration { "additionalConfiguration": { - "_dd.react_native_version": "${reactNativeVersion}", + "_dd.react_native_version": "0.76.9", "_dd.source": "react-native", }, "attributeEncoders": [], @@ -91,6 +91,7 @@ describe('DatadogProvider', () => { "customEndpoint": undefined, "errorEventMapper": null, "firstPartyHosts": [], + "headerCaptureRules": undefined, "initialResourceThreshold": undefined, "longTaskThresholdMs": 0, "nativeCrashReportEnabled": false,