diff --git a/.gitignore b/.gitignore index 693bb50a6..a070a8854 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,5 @@ packages/react-native-babel-plugin/svg-map.json # Session Replay assets packages/react-native-session-replay/assets/* + +.planning diff --git a/packages/core/README.md b/packages/core/README.md index 6b61e7510..e8a246014 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -83,7 +83,8 @@ export default function App() { `trackFetchResources` tracks calls made through the Expo-installed global `fetch`. Direct imports retained from `expo/fetch` are not intercepted. XHR -tracking remains enabled for clients such as axios. +tracking remains enabled for clients such as axios. Resource header capture +(`headerCaptureRules`) applies to both XHR and Fetch resources. GraphQL error extraction (via `DatadogLink({ trackErrors: true })`) for requests made through `expo/fetch` requires Expo SDK 56 or later, as older diff --git a/packages/core/datadog-configuration.schema.json b/packages/core/datadog-configuration.schema.json index c06bc5de4..5a7c112b0 100644 --- a/packages/core/datadog-configuration.schema.json +++ b/packages/core/datadog-configuration.schema.json @@ -192,6 +192,125 @@ "description": "The app hang threshold in seconds for non-fatal app hangs on iOS.", "type": "number" }, + "headerCaptureRules": { + "description": "Controls which HTTP request and response headers are captured on RUM resource events. Omit to disable header capture (default). Use `\"defaults\"` to capture a predefined set of caching and content headers on all URLs, or an array of composable rules. Sensitive headers (cookies, authorization, tokens, ...) and SDK tracing headers are never captured. Only takes effect when resource tracking is enabled.", + "oneOf": [ + { + "const": "defaults" + }, + { + "type": "array", + "items": { + "oneOf": [ + { + "description": "Captures the predefined set of caching and content headers from requests and responses.", + "type": "object", + "properties": { + "type": { + "const": "defaults" + }, + "forURLs": { + "description": "URL patterns to scope this rule to. Supports hostname-only (`api.example.com`) or hostname + path prefix (`api.example.com/v2`). Omit (or use `[\"*\"]`) to match all URLs.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "description": "Captures the given headers from both requests and responses.", + "type": "object", + "properties": { + "type": { + "const": "matchHeaders" + }, + "headers": { + "description": "Header names to capture. Compared case-insensitively; the casing provided here is used in the reported attributes.", + "type": "array", + "items": { + "type": "string" + } + }, + "forURLs": { + "description": "URL patterns to scope this rule to. Supports hostname-only (`api.example.com`) or hostname + path prefix (`api.example.com/v2`). Omit (or use `[\"*\"]`) to match all URLs.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "headers" + ], + "additionalProperties": false + }, + { + "description": "Captures the given headers from requests only.", + "type": "object", + "properties": { + "type": { + "const": "matchRequestHeaders" + }, + "headers": { + "description": "Header names to capture. Compared case-insensitively; the casing provided here is used in the reported attributes.", + "type": "array", + "items": { + "type": "string" + } + }, + "forURLs": { + "description": "URL patterns to scope this rule to. Supports hostname-only (`api.example.com`) or hostname + path prefix (`api.example.com/v2`). Omit (or use `[\"*\"]`) to match all URLs.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "headers" + ], + "additionalProperties": false + }, + { + "description": "Captures the given headers from responses only.", + "type": "object", + "properties": { + "type": { + "const": "matchResponseHeaders" + }, + "headers": { + "description": "Header names to capture. Compared case-insensitively; the casing provided here is used in the reported attributes.", + "type": "array", + "items": { + "type": "string" + } + }, + "forURLs": { + "description": "URL patterns to scope this rule to. Supports hostname-only (`api.example.com`) or hostname + path prefix (`api.example.com/v2`). Omit (or use `[\"*\"]`) to match all URLs.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "headers" + ], + "additionalProperties": false + } + ] + } + } + ] + }, "initialResourceThreshold": { "description": "The amount of time after a view starts where a Resource should be considered when calculating Time to Network-Settled (TNS).", "type": "number" diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index b74605ddb..394c02433 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -518,6 +518,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; @@ -553,11 +555,19 @@ export class DdSdkReactNative { }); } + if (!trackResources && headerCaptureRules != null) { + InternalLog.log( + 'headerCaptureRules is set but trackResources is false. Header capture will be disabled.', + SdkVerbosity.WARN + ); + } + if (trackResources) { DdRumResourceTracking.startTracking({ resourceTraceSampleRate, firstPartyHosts, - trackFetchResources + trackFetchResources, + headerCaptureRules }); } diff --git a/packages/core/src/__tests__/DdSdkReactNative.test.tsx b/packages/core/src/__tests__/DdSdkReactNative.test.tsx index 1b35fc49e..d11c28526 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -8,6 +8,7 @@ import { version as reactNativeVersion } from 'react-native/package.json'; import { NativeModules } from 'react-native'; import { DdSdkReactNative } from '../DdSdkReactNative'; +import { InternalLog } from '../InternalLog'; import type { DdSdkNativeConfiguration } from '../config/features/CoreConfigurationNative'; import { CoreConfiguration } from '../config/features/CoreConfiguration'; import { LogsConfiguration } from '../config/features/LogsConfiguration'; @@ -813,10 +814,130 @@ describe('DdSdkReactNative', () => { propagatorTypes: ['datadog'] } ], - trackFetchResources: true + trackFetchResources: true, + headerCaptureRules: undefined }); }); + it('does not enable header capture when headerCaptureRules is omitted', async () => { + // GIVEN + const fakeAppId = '1'; + const fakeClientToken = '2'; + const fakeEnvName = 'env'; + const configuration = new CoreConfiguration( + fakeClientToken, + fakeEnvName + ); + configuration.rumConfiguration = new RumConfiguration( + fakeAppId, + false, + true + ); + + NativeModules.DdSdk.initialize.mockResolvedValue(null); + + // WHEN + await DdSdkReactNative.initialize(configuration); + + // THEN + expect(DdRumResourceTracking.startTracking).toHaveBeenCalledWith( + expect.objectContaining({ + headerCaptureRules: undefined + }) + ); + }); + + it('enables header capture with the default headerCaptureRules preset', async () => { + // GIVEN + const fakeAppId = '1'; + const fakeClientToken = '2'; + const fakeEnvName = 'env'; + const configuration = new CoreConfiguration( + fakeClientToken, + fakeEnvName + ); + configuration.rumConfiguration = new RumConfiguration( + fakeAppId, + false, + true + ); + configuration.rumConfiguration.headerCaptureRules = 'defaults'; + + NativeModules.DdSdk.initialize.mockResolvedValue(null); + + // WHEN + await DdSdkReactNative.initialize(configuration); + + // THEN + expect(DdRumResourceTracking.startTracking).toHaveBeenCalledWith( + expect.objectContaining({ + headerCaptureRules: 'defaults' + }) + ); + }); + + it('enables header capture with custom headerCaptureRules', async () => { + // GIVEN + const fakeAppId = '1'; + const fakeClientToken = '2'; + const fakeEnvName = 'env'; + const configuration = new CoreConfiguration( + fakeClientToken, + fakeEnvName + ); + configuration.rumConfiguration = new RumConfiguration( + fakeAppId, + false, + true + ); + configuration.rumConfiguration.headerCaptureRules = [ + { type: 'matchHeaders', headers: ['x-request-id'] } + ]; + + NativeModules.DdSdk.initialize.mockResolvedValue(null); + + // WHEN + await DdSdkReactNative.initialize(configuration); + + // THEN + expect(DdRumResourceTracking.startTracking).toHaveBeenCalledWith( + expect.objectContaining({ + headerCaptureRules: [ + { type: 'matchHeaders', headers: ['x-request-id'] } + ] + }) + ); + }); + + it('logs a warning and disables header capture when headerCaptureRules is set but trackResources is false', async () => { + // GIVEN + const fakeAppId = '1'; + const fakeClientToken = '2'; + const fakeEnvName = 'env'; + const configuration = new CoreConfiguration( + fakeClientToken, + fakeEnvName + ); + configuration.rumConfiguration = new RumConfiguration( + fakeAppId, + false, + false + ); + configuration.rumConfiguration.headerCaptureRules = 'defaults'; + + NativeModules.DdSdk.initialize.mockResolvedValue(null); + + // WHEN + await DdSdkReactNative.initialize(configuration); + + // THEN + expect(DdRumResourceTracking.startTracking).not.toHaveBeenCalled(); + expect(InternalLog.log).toHaveBeenCalledWith( + 'headerCaptureRules is set but trackResources is false. Header capture will be disabled.', + SdkVerbosity.WARN + ); + }); + it('enables error tracking feature when initialize { error tracking config enabled }', async () => { // GIVEN const fakeAppId = '1'; diff --git a/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts b/packages/core/src/__tests__/DdSdkReactNativeConfiguration.test.ts index 295bf5011..5f6285767 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, @@ -212,6 +213,7 @@ describe('DdSdkReactNativeConfiguration', () => { ], }, ], + "headerCaptureRules": undefined, "initialResourceThreshold": 0.123, "longTaskThresholdMs": 567, "nativeCrashReportEnabled": true, @@ -320,6 +322,7 @@ describe('DdSdkReactNativeConfiguration', () => { "customEndpoint": undefined, "errorEventMapper": null, "firstPartyHosts": [], + "headerCaptureRules": undefined, "initialResourceThreshold": 0, "longTaskThresholdMs": false, "nativeCrashReportEnabled": false, diff --git a/packages/core/src/config/FileBasedConfiguration.ts b/packages/core/src/config/FileBasedConfiguration.ts index 508261580..5e63dcb45 100644 --- a/packages/core/src/config/FileBasedConfiguration.ts +++ b/packages/core/src/config/FileBasedConfiguration.ts @@ -165,6 +165,9 @@ export const getJSONConfiguration = ( buildFirstPartyHosts( configuration.rumConfiguration.firstPartyHosts ) || RUM_DEFAULTS.getFirstPartyHosts(), + // Validated when compiled at resource tracking start + headerCaptureRules: + configuration.rumConfiguration.headerCaptureRules, useAccessibilityLabel: configuration.rumConfiguration.useAccessibilityLabel, trackInteractions: diff --git a/packages/core/src/config/FileBasedConfiguration.type.ts b/packages/core/src/config/FileBasedConfiguration.type.ts index 1fb8cff33..8e82283eb 100644 --- a/packages/core/src/config/FileBasedConfiguration.type.ts +++ b/packages/core/src/config/FileBasedConfiguration.type.ts @@ -6,6 +6,7 @@ import type { FirstPartyHost } from '../rum/types'; import type { CoreConfigurationOptions } from './features/CoreConfiguration.type'; +import type { HeaderCaptureRule } from './features/RumConfiguration.type'; import type { BatchProcessingLevel, BatchSize, @@ -40,6 +41,7 @@ export interface JsonConfiguration extends CoreConfigurationOptions { actionNameAttribute?: string; appHangThreshold?: number; firstPartyHosts?: FirstPartyHost[]; + headerCaptureRules?: 'defaults' | HeaderCaptureRule[]; initialResourceThreshold?: number; trackMemoryWarnings?: boolean; nativeCrashReportEnabled?: boolean; diff --git a/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts b/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts index 93fe175c9..f0aa030bc 100644 --- a/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts +++ b/packages/core/src/config/__tests__/FileBasedConfiguration.test.ts @@ -55,6 +55,20 @@ describe('FileBasedConfiguration', () => { ], }, ], + "headerCaptureRules": [ + { + "type": "defaults", + }, + { + "forURLs": [ + "api.example.com", + ], + "headers": [ + "x-request-id", + ], + "type": "matchResponseHeaders", + }, + ], "initialResourceThreshold": 456, "longTaskThresholdMs": 44, "nativeCrashReportEnabled": true, @@ -172,6 +186,7 @@ describe('FileBasedConfiguration', () => { ], }, ], + "headerCaptureRules": undefined, "initialResourceThreshold": undefined, "longTaskThresholdMs": 44, "nativeCrashReportEnabled": false, @@ -239,6 +254,7 @@ describe('FileBasedConfiguration', () => { "customEndpoint": undefined, "errorEventMapper": null, "firstPartyHosts": [], + "headerCaptureRules": undefined, "initialResourceThreshold": undefined, "longTaskThresholdMs": 0, "nativeCrashReportEnabled": false, @@ -346,6 +362,56 @@ describe('FileBasedConfiguration', () => { ).toEqual(disallowList); }); + it('leaves headerCaptureRules undefined when not set', () => { + const config = new FileBasedConfiguration({ + configuration: { + env: 'fake-env', + clientToken: 'fake-client-token', + rumConfiguration: { + applicationId: 'fake-app-id' + } + } + }); + expect(config.rumConfiguration?.headerCaptureRules).toBeUndefined(); + }); + + it('passes the headerCaptureRules defaults preset through', () => { + const config = new FileBasedConfiguration({ + configuration: { + env: 'fake-env', + clientToken: 'fake-client-token', + rumConfiguration: { + applicationId: 'fake-app-id', + headerCaptureRules: 'defaults' + } + } + }); + expect(config.rumConfiguration?.headerCaptureRules).toBe( + 'defaults' + ); + }); + + it('passes headerCaptureRules rules through', () => { + const rules = [ + { + type: 'matchRequestHeaders', + headers: ['x-device-id'], + forURLs: ['api.example.com/v2'] + } + ]; + const config = new FileBasedConfiguration({ + configuration: { + env: 'fake-env', + clientToken: 'fake-client-token', + rumConfiguration: { + applicationId: 'fake-app-id', + headerCaptureRules: rules + } + } + }); + expect(config.rumConfiguration?.headerCaptureRules).toEqual(rules); + }); + it('applies event mappers to configuration when provided', () => { const actionEventMapper = () => null; const errorEventMapper = () => null; diff --git a/packages/core/src/config/__tests__/__fixtures__/configuration-all-fields.json b/packages/core/src/config/__tests__/__fixtures__/configuration-all-fields.json index c9f4a434e..d566506e1 100644 --- a/packages/core/src/config/__tests__/__fixtures__/configuration-all-fields.json +++ b/packages/core/src/config/__tests__/__fixtures__/configuration-all-fields.json @@ -25,6 +25,14 @@ ] } ], + "headerCaptureRules": [ + { "type": "defaults" }, + { + "type": "matchResponseHeaders", + "headers": ["x-request-id"], + "forURLs": ["api.example.com"] + } + ], "initialResourceThreshold": 456, "nativeViewTracking": true, "nativeInteractionTracking": true, diff --git a/packages/core/src/config/async/AutoInstrumentationConfiguration.ts b/packages/core/src/config/async/AutoInstrumentationConfiguration.ts index e4fdd787b..ee636a460 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'; @@ -25,6 +26,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; @@ -61,6 +63,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; @@ -128,7 +131,8 @@ export const addDefaultValuesToAutoInstrumentationConfiguration = ( features.rumConfiguration.nativeIosResourceTrackingDisallowList, 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 2a257b578..41d389626 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'; @@ -40,6 +41,10 @@ const DEFAULTS = { trackInteractions: false, trackMemoryWarnings: true, trackNonFatalAnrs: undefined, + headerCaptureRules: undefined as + | 'defaults' + | HeaderCaptureRule[] + | undefined, trackResources: false, trackWatchdogTerminations: false, useAccessibilityLabel: true, @@ -126,6 +131,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 be8cc48e1..e7bc26df8 100644 --- a/packages/core/src/config/features/RumConfiguration.type.ts +++ b/packages/core/src/config/features/RumConfiguration.type.ts @@ -37,6 +37,108 @@ 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', 'x-request-id'). + * 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. 'x-request-id', 'if-none-match') 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). + * If at least one scoped rule (explicit `forURLs` patterns) matches a URL, + * catch-all rules (omitted `forURLs` / `['*']`) are ignored for that URL. + */ +export type HeaderCaptureRule = + | DefaultsRule + | MatchHeadersRule + | MatchRequestHeadersRule + | MatchResponseHeadersRule; + /** * Optional RUM configuration values. */ @@ -79,6 +181,21 @@ export interface RumConfigurationOptions { */ firstPartyHosts?: FirstPartyHost[]; + /** + * Controls which resource headers the SDK captures on network requests. + * + * - **Omitted** (default): No headers are captured. + * - `'defaults'`: Captures a predefined set of caching and content headers + * across all URLs. + * - `HeaderCaptureRule[]`: An array of composable rules. When multiple rules + * match a URL their header sets are merged. If at least one scoped rule + * (with explicit `forURLs` patterns) matches, catch-all rules (no `forURLs` + * or `['*']`) are ignored for that URL. + * + * 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 fc704bc81..9ec787665 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -116,6 +116,13 @@ export { __ddExtractText, __ddEnrichEvaluationContextWithRumUser }; +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 7204bc5c4..a470974a6 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 { FetchProxy } from './requestProxy/FetchProxy/FetchProxy'; import { XHRProxy } from './requestProxy/XHRProxy/XHRProxy'; import type { RequestProxy } from './requestProxy/interfaces/RequestProxy'; @@ -42,11 +44,13 @@ class RumResourceTracking { startTracking({ resourceTraceSampleRate, firstPartyHosts, - trackFetchResources = false + trackFetchResources = false, + headerCaptureRules }: { resourceTraceSampleRate: number; firstPartyHosts: FirstPartyHost[]; trackFetchResources?: boolean; + headerCaptureRules?: 'defaults' | HeaderCaptureRule[] | undefined; }): void { // extra safety to avoid proxying the XHR class twice if (this._isTracking) { @@ -61,7 +65,8 @@ class RumResourceTracking { tracingSamplingRate: resourceTraceSampleRate, firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder( firstPartyHosts - ) + ), + headerCaptureConfig: compileHeaderCaptureConfig(headerCaptureRules) }; const xhrProxy = XHRProxy.createWithResourceReporter(); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/__tests__/DdRumResourceTracking.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/__tests__/DdRumResourceTracking.test.ts index d578fbb57..013213a26 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/__tests__/DdRumResourceTracking.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/__tests__/DdRumResourceTracking.test.ts @@ -193,6 +193,52 @@ describe('DdRumResourceTracking', () => { ); }); + it('captures headers once on an XHR-backed Fetch when header capture is enabled', async () => { + const fetchResponse = ({ + status: 200, + headers: new Headers({ 'cache-control': 'max-age=60' }) + } as unknown) as Response; + const xhrBackedFetch = jest.fn( + (input: RequestInfo | URL, init?: RequestInit) => { + const xhr = new XMLHttpRequestMock(); + xhr.open(init?.method ?? 'GET', String(input)); + new Headers(init?.headers).forEach((value, header) => { + xhr.setRequestHeader(header, value); + }); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'cache-control: max-age=60\r\n' + ); + xhr.complete(200, 'ok'); + return Promise.resolve(fetchResponse); + } + ) as jest.MockedFunction; + global.fetch = xhrBackedFetch; + + DdRumResourceTracking.startTracking({ + resourceTraceSampleRate: 100, + firstPartyHosts: [], + trackFetchResources: true, + headerCaptureRules: 'defaults' + }); + + await global.fetch('https://api.example.com/fetch', { + headers: { 'Content-Type': 'application/json' } + }); + await flushPromises(); + + expect(DdRum.stopResource).toHaveBeenCalledTimes(1); + const [, , kind, , context] = DdRum.stopResource.mock.calls[0]; + expect(kind).toBe('fetch'); + expect(context['_dd.request_headers']).toEqual({ + 'Content-Type': 'application/json' + }); + expect(context['_dd.response_headers']).toEqual({ + 'Cache-Control': 'max-age=60' + }); + }); + describe('updateTrackingContext', () => { beforeEach(() => { DdRumResourceTracking.stopTracking(); 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..de47b8c72 --- /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 extra instrumentation. + +Header capture is **disabled by default** and requires `trackResources: true`. + +## Configuration + +Set `headerCaptureRules` in your RUM configuration: + +```typescript +import { + DatadogProvider, + DatadogProviderConfiguration, + TrackingConsent +} from '@datadog/mobile-react-native'; + +const config = new DatadogProviderConfiguration( + '', + '', + TrackingConsent.GRANTED, + { + rumConfiguration: { + applicationId: '', + trackResources: true, + headerCaptureRules: 'defaults' + } + } +); + +// ... +``` + +With `FileBasedConfiguration`, set the same value under `rumConfiguration` in your JSON configuration file: + +```json +{ + "configuration": { + "rumConfiguration": { + "trackResources": true, + "headerCaptureRules": "defaults" + } + } +} +``` + +### Modes + +| Value | What gets captured | +|---|---| +| Omitted (default) | Nothing | +| `'defaults'` | A predefined set of caching and content headers across all URLs | +| `HeaderCaptureRule[]` | Custom rules — see below | + +### Default headers + +`'defaults'` captures: + +- **Response:** `Cache-Control`, `ETag`, `Age`, `Expires`, `Content-Type`, `Content-Encoding`, `Content-Length`, `Vary`, `Server-Timing`, `X-Cache` +- **Request:** `Cache-Control`, `Content-Type` + +### Custom rules + +Use an array of rules to control exactly which headers are captured and for which URLs: + +```typescript +headerCaptureRules: [ + // Capture default headers everywhere + { type: 'defaults' }, + + // Capture a custom response header on a specific API + { + type: 'matchResponseHeaders', + headers: ['x-request-id', 'x-ratelimit-remaining'], + forURLs: ['api.example.com'] + }, + + // Capture a custom request header on a path prefix + { + type: 'matchRequestHeaders', + headers: ['x-device-id'], + forURLs: ['api.example.com/v2'] + } +] +``` + +Each rule has a `type`: + +| `type` | Captures from | +|---|---| +| `'defaults'` | Request + response (predefined set) | +| `'matchHeaders'` | Both request and response (named headers) | +| `'matchRequestHeaders'` | Request only | +| `'matchResponseHeaders'` | Response only | + +The optional `forURLs` field scopes a rule to specific URL patterns. Supports hostname-only (`api.example.com`) or hostname + path prefix (`api.example.com/v2`). Omit it (or use `['*']`) to match all URLs. + +- A hostname matches itself and its subdomains (`api.example.com` matches `cdn.api.example.com`), but never a hostname that only appears in the path or query of another URL. +- A path prefix matches whole path segments: `api.example.com/v2` matches `/v2` and `/v2/users`, but not `/v22`. +- URLs are matched the way HTTP clients resolve them: `..` and `.` segments (including `%2e` encodings) are resolved first, so `api.example.com/public/../private` does not match `api.example.com/public`. Equivalent hostname spellings are normalized (percent-encodings, fullwidth characters, `。`/`.`/`。` label separators, extra slashes after `https:`), and a URL without a path (`https://api.example.com?x=1`) matches as `/`. Hostnames in `forURLs` are normalized the same way. Hostnames match case-insensitively, paths case-sensitively. + +**Rule precedence:** when multiple rules match a URL, their header sets are merged. However, if at least one scoped rule (with explicit `forURLs` patterns) matches, catch-all rules (no `forURLs`, or a `forURLs` list containing `'*'`) are ignored for that URL. For example, `[{ type: 'defaults' }, { type: 'matchResponseHeaders', headers: ['x-request-id'], forURLs: ['api.example.com'] }]` captures only `x-request-id` on `api.example.com`, and the default headers everywhere else. + +## Output + +Captured headers appear as attributes on RUM resource events: + +- `_dd.request_headers` — captured request headers +- `_dd.response_headers` — captured response headers + +Header names use RFC Title-Case for default headers (`Cache-Control`, `ETag`, …) and preserve the casing from your rule's `headers` array for custom headers. + +When nothing is captured these attributes are omitted entirely. + +## Supported clients + +Headers are captured for every request tracked by the SDK: + +- **XHR** (`trackResources: true`) — covers React Native's built-in `fetch`, axios and any other XMLHttpRequest-based client. +- **Global Fetch** (`trackFetchResources: true`) — covers the Expo-installed global `fetch`. When the global `fetch` is backed by XHR, headers are reported once, on the Fetch resource. Direct imports retained from `expo/fetch` are not intercepted. + +Fetch `Headers` lowercase header names, so on Fetch resources custom request headers use the casing from your rule's `headers` array rather than the casing passed to `fetch`. + +Requests that fail without a response (aborted, network error) never report headers. + +## Security + +Two filters are always enforced, regardless of configuration: + +**Sensitive header blocklist** — headers matching patterns like `token`, `cookie`, `secret`, `authorization`, `password`, `bearer`, `api-key`, `x-forwarded-for`, etc. are always blocked and never stored. + +**SDK tracing headers** — the Datadog and W3C/B3 tracing headers injected by the SDK itself (`x-datadog-trace-id`, `traceparent`, `b3`, etc.) are always excluded. + +## Size limits + +To protect event size, the SDK enforces: + +- **128 bytes** max per header value (values are truncated) +- **100 headers** max total (request + response combined) +- **2 048 bytes** max total across all header names and values + +All limits are measured in UTF-8 bytes. 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..44bb997bc --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/captureHeaders.test.ts @@ -0,0 +1,853 @@ +/* + * 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 { compileHeaderCaptureConfig } from '../compileHeaderConfig'; +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('applies a scoped rule over catch-all rules for percent-encoded hosts', () => { + const config = compileHeaderCaptureConfig([ + { type: 'defaults' }, + { + type: 'matchResponseHeaders', + headers: ['x-request-id'], + forURLs: ['api.example.com/v2'] + } + ]); + const raw = 'cache-control: no-cache\r\nx-request-id: abc\r\n'; + + expect( + captureResponseHeaders( + raw, + 'https://%61pi.example.com/v2/users', + config + ) + ).toEqual({ 'x-request-id': 'abc' }); + }); + + it.each([ + 'https://api\uFF0Eexample.com/v2/users', + 'https://api%EF%BC%8Eexample.com/v2/users', + 'https:///api.example.com/v2/users' + ])( + 'applies a scoped rule over catch-all rules for the equivalent spelling %s', + url => { + const config = compileHeaderCaptureConfig([ + { type: 'defaults' }, + { + type: 'matchResponseHeaders', + headers: ['x-request-id'], + forURLs: ['api.example.com/v2'] + } + ]); + const raw = + 'cache-control: no-cache\r\nx-request-id: abc\r\n'; + + expect(captureResponseHeaders(raw, url, config)).toEqual({ + 'x-request-id': 'abc' + }); + } + ); + + it('matches forURLs hosts case-insensitively and paths case-sensitively', () => { + const config = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com/Public'] + } + ]); + const raw = 'etag: abc\r\n'; + + expect( + captureResponseHeaders( + raw, + 'HTTPS://API.EXAMPLE.COM/Public/users', + config + ) + ).toEqual({ etag: 'abc' }); + expect( + captureResponseHeaders( + raw, + 'https://api.example.com/public/users', + config + ) + ).toBeUndefined(); + expect( + filterRequestHeadersByMode( + { etag: 'abc' }, + 'https://api.example.com/public/users', + config + ) + ).toBeUndefined(); + }); + + 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..261e086f4 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/compileHeaderConfig.test.ts @@ -0,0 +1,697 @@ +/* + * 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'; +import { normalizeUrlForMatching } from '../normalizeUrlForMatching'; + +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('forURLs containing "*" alongside other patterns is catch-all', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchHeaders', + headers: ['x-foo'], + forURLs: ['*', 'api.example.com'] + } + ]); + const { urlRegex, isScoped } = (result ?? [])[0]; + expect(isScoped).toBe(false); + expect(urlRegex.test('https://other.com/path')).toBe(true); + }); + + 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); + // Dots in the hostname are escaped (literal), not wildcards + expect(urlRegex.test('https://apiXexampleYcom/any')).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 + ); + // Dots in hostname and path are escaped (literal), not wildcards + expect(urlRegex.test('https://apiXexampleYcom/v2/users')).toBe( + false + ); + }); + + it('forURLs path prefix ends on a path segment boundary', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com/v2'] + } + ]); + const { urlRegex } = (result ?? [])[0]; + expect(urlRegex.test('https://api.example.com/v2')).toBe(true); + expect(urlRegex.test('https://api.example.com/v2?page=1')).toBe( + true + ); + expect(urlRegex.test('https://api.example.com/v2#top')).toBe(true); + expect(urlRegex.test('https://api.example.com/v22/users')).toBe( + false + ); + expect(urlRegex.test('https://api.example.com/v2-beta')).toBe( + false + ); + }); + + it('forURLs matches URLs with userinfo on the configured host', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com/v2'] + } + ]); + const { urlRegex } = (result ?? [])[0]; + expect( + urlRegex.test('https://user:pass@api.example.com/v2/users') + ).toBe(true); + expect(urlRegex.test('https://user@cdn.api.example.com/v2')).toBe( + true + ); + expect( + urlRegex.test('https://user:pass@api.example.com/v1/users') + ).toBe(false); + }); + + it('forURLs path scope applies to the resolved path', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com/public'] + } + ]); + const { urlRegex } = (result ?? [])[0]; + const matches = (url: string) => + urlRegex.test(normalizeUrlForMatching(url)); + + expect(matches('https://api.example.com/public/../private')).toBe( + false + ); + expect( + matches('https://api.example.com/public/%2e%2e/private') + ).toBe(false); + expect(matches('https://api.example.com/public/\t../private')).toBe( + false + ); + expect(matches('https://api.example.com/other/../public/x')).toBe( + true + ); + }); + + it('forURLs matches the host case-insensitively and the path case-sensitively', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['API.Example.com/Public'] + } + ]); + const { urlRegex } = (result ?? [])[0]; + const matches = (url: string) => + urlRegex.test(normalizeUrlForMatching(url)); + + expect(matches('https://api.example.com/Public/users')).toBe(true); + expect(matches('HTTPS://API.EXAMPLE.COM/Public/users')).toBe(true); + expect(matches('https://api.example.com/public/users')).toBe(false); + expect(matches('https://api.example.com/PUBLIC/users')).toBe(false); + }); + + it('forURLs normalizes configured hostnames like request hostnames', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['API\uFF0EExample.com/v2'] + } + ]); + const { urlRegex } = (result ?? [])[0]; + + expect( + urlRegex.test( + normalizeUrlForMatching('https://api.example.com/v2/users') + ) + ).toBe(true); + }); + + it('forURLs root path scope matches requests without a path', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com/'] + } + ]); + const { urlRegex } = (result ?? [])[0]; + const matches = (url: string) => + urlRegex.test(normalizeUrlForMatching(url)); + + expect(matches('https://api.example.com')).toBe(true); + expect(matches('https://api.example.com?view=home')).toBe(true); + expect(matches('https://api.example.com#top')).toBe(true); + expect(matches('https://api.example.com/users')).toBe(true); + expect(matches('https://other.example.com')).toBe(false); + }); + + it('forURLs path prefix ending with a slash matches nested paths', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com/v2/'] + } + ]); + const { urlRegex } = (result ?? [])[0]; + expect(urlRegex.test('https://api.example.com/v2/users')).toBe( + true + ); + expect(urlRegex.test('https://api.example.com/v2')).toBe(false); + }); + + it('forURLs matches subdomains but not hostnames in the path, query or userinfo', () => { + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['api.example.com'] + } + ]); + const { urlRegex } = (result ?? [])[0]; + expect(urlRegex.test('https://cdn.api.example.com/users')).toBe( + true + ); + expect(urlRegex.test('https://a.b.api.example.com:8443/x')).toBe( + true + ); + expect( + urlRegex.test( + 'https://unrelated.test/assets.api.example.com/users' + ) + ).toBe(false); + expect( + urlRegex.test( + 'https://unrelated.test/?next=cdn.api.example.com/users' + ) + ).toBe(false); + expect( + urlRegex.test('https://unrelated.test#x.api.example.com') + ).toBe(false); + expect( + urlRegex.test('https://user.api.example.com@unrelated.test/') + ).toBe(false); + expect(urlRegex.test('https://api.example.com.evil.test/')).toBe( + false + ); + expect(urlRegex.test('https://notapi.example.com/')).toBe(false); + expect( + urlRegex.test('https://api.example.com@unrelated.test/users') + ).toBe(false); + expect( + urlRegex.test( + 'https://user:pass@unrelated.test/api.example.com' + ) + ).toBe(false); + // URL parsers treat '\\' as '/', so this request goes to unrelated.test + expect( + urlRegex.test('https://unrelated.test\\.api.example.com/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 special regex characters treats them as literal hostname characters', () => { + // Metacharacters like '[' are escaped by escapeRegExp so they become literal matches + const result = compileHeaderCaptureConfig([ + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: ['[special].example.com'] + } + ]); + expect(result).not.toBeNull(); + const rule0 = (result ?? [])[0]; + expect(rule0.isScoped).toBe(true); + // The pattern is treated literally: it matches the host '[special].example.com' + expect( + rule0.urlRegex.test('https://[special].example.com/path') + ).toBe(true); + // It does NOT match hosts that would match the unescaped regex interpretation + expect( + rule0.urlRegex.test('https://xspecialx.example.com/path') + ).toBe(false); + }); + + 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); + // Dots in hostnames are escaped (literal), not wildcards + expect(urlRegex.test('https://apiXexampleYcom/v2')).toBe(false); + expect(urlRegex.test('https://cdnXexampleYcom/assets')).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('malformed rules', () => { + it.each([ + ['null', null], + ['a string', 'defaults'], + ['a rule without type', { headers: ['etag'] }], + ['a rule with an unknown type', { type: 'all', headers: ['etag'] }], + ['a match rule without headers', { type: 'matchHeaders' }], + [ + 'a match rule with non-string headers', + { type: 'matchRequestHeaders', headers: ['etag', 42] } + ], + [ + 'a rule with null forURLs', + { + type: 'matchResponseHeaders', + headers: ['etag'], + forURLs: null + } + ], + [ + 'a rule with non-array forURLs', + { type: 'defaults', forURLs: 'api.example.com' } + ], + ['a rule with a non-string type', { type: ['defaults'] }], + [ + 'a rule whose type cannot be converted to a string', + { type: { toString: null } } + ] + ])('skips %s with a WARN log instead of throwing', (_, rule) => { + const result = compileHeaderCaptureConfig([ + rule, + { type: 'matchResponseHeaders', headers: ['etag'] } + ] as any); + + expect(result).toHaveLength(1); + expect(result?.[0].responseHeaderNames).toEqual(new Set(['etag'])); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining('Skipping invalid rule'), + SdkVerbosity.WARN + ); + }); + + it('returns null when every rule is malformed', () => { + expect( + compileHeaderCaptureConfig([ + null, + { type: 'matchHeaders' } + ] as any) + ).toBeNull(); + }); + }); + + 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..22464bd08 --- /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 { XHRProxy } from '../../requestProxy/XHRProxy/XHRProxy'; +import { ResourceReporter } from '../../requestProxy/common/ResourceReporter'; +import { + DEFAULT_REQUEST_HEADERS, + DEFAULT_RESPONSE_HEADERS, + CANONICAL_REQUEST_HEADERS, + CANONICAL_RESPONSE_HEADERS +} from '../captureHeaders'; +import * as enforceSizeLimitsModule from '../enforceSizeLimits'; +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 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..61c4f4a2c --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/enforceSizeLimits.test.ts @@ -0,0 +1,337 @@ +/* + * 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'; + +// Node's encoder is the reference: lone surrogates are encoded as U+FFFD (3 bytes) +const bytesOf = (value: string) => Buffer.byteLength(value, 'utf8'); + +const truncateValue = (value: string): string => + enforceSizeLimits({ h: value }, undefined).requestHeaders?.h ?? ''; + +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('UTF-8 value truncation', () => { + it.each([ + ['2-byte characters exactly at the limit', 'é'.repeat(64), 64], + ['3-byte characters below the limit', '€'.repeat(42), 42], + [ + 'an emoji ending exactly at the limit', + `${'a'.repeat(124)}😀`, + 126 + ], + [ + 'a lone high surrogate ending at the limit', + `${'a'.repeat(125)}\uD800`, + 126 + ], + [ + 'a lone low surrogate ending at the limit', + `${'a'.repeat(125)}\uDC00`, + 126 + ] + ])('keeps %s intact', (_, value, expectedLength) => { + expect(bytesOf(value)).toBeLessThanOrEqual(MAX_HEADER_VALUE_BYTES); + expect(truncateValue(value)).toBe(value); + expect(value).toHaveLength(expectedLength); + }); + + it.each([ + [ + 'a 2-byte character crossing the limit', + `a${'é'.repeat(64)}`, + `a${'é'.repeat(63)}` + ], + [ + 'a 3-byte character crossing the limit', + '€'.repeat(43), + '€'.repeat(42) + ], + [ + 'an emoji crossing the limit', + `${'a'.repeat(126)}😀`, + 'a'.repeat(126) + ], + [ + 'an emoji starting right after the limit', + `${'a'.repeat(128)}😀`, + 'a'.repeat(128) + ], + [ + 'a lone high surrogate crossing the limit', + `${'a'.repeat(126)}\uD800`, + 'a'.repeat(126) + ], + [ + 'a lone low surrogate crossing the limit', + `${'a'.repeat(126)}\uDC00`, + 'a'.repeat(126) + ] + ])('drops %s without splitting it', (_, value, expected) => { + const truncated = truncateValue(value); + expect(truncated).toBe(expected); + expect(bytesOf(truncated)).toBeLessThanOrEqual( + MAX_HEADER_VALUE_BYTES + ); + }); + + it('never leaves a dangling high surrogate after truncation', () => { + for (let prefix = 120; prefix <= 128; prefix++) { + const truncated = truncateValue( + `${'a'.repeat(prefix)}${'😀'.repeat(4)}` + ); + const lastCode = truncated.charCodeAt(truncated.length - 1); + expect(lastCode >= 0xd800 && lastCode <= 0xdbff).toBe(false); + expect(bytesOf(truncated)).toBeLessThanOrEqual( + MAX_HEADER_VALUE_BYTES + ); + } + }); + }); + + 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('total size budget with multi-byte values', () => { + it('measures the budget in UTF-8 bytes, not UTF-16 code units', () => { + // 20 headers of 3 + 126 = 129 UTF-8 bytes (45 UTF-16 code units each): + // 900 code units in total, but 2580 bytes + const responseHeaders: Record = {}; + for (let i = 0; i < 20; i++) { + responseHeaders[`h${String(i).padStart(2, '0')}`] = '€'.repeat( + 42 + ); + } + + const result = enforceSizeLimits(undefined, responseHeaders); + const kept = Object.entries(result.responseHeaders ?? {}); + const keptBytes = kept.reduce( + (total, [name, value]) => + total + bytesOf(name) + bytesOf(value), + 0 + ); + + expect(kept).toHaveLength(15); + expect(keptBytes).toBe(15 * 129); + expect(keptBytes).toBeLessThanOrEqual(MAX_TOTAL_BYTES); + }); + }); + + 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__/normalizeUrlForMatching.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts new file mode 100644 index 000000000..a251eab66 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/normalizeUrlForMatching.test.ts @@ -0,0 +1,176 @@ +/* + * 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 { + normalizeHostname, + normalizeUrlForMatching +} from '../normalizeUrlForMatching'; + +describe('normalizeUrlForMatching', () => { + it.each([ + [ + 'HTTPS://API.Example.com/Public/Users?Q=A#Top', + 'https://api.example.com/Public/Users?Q=A#Top' + ], + ['https://API.example.com:8443', 'https://api.example.com:8443/'], + ['https://API.example.com?Q=A', 'https://api.example.com/?Q=A'], + ['https://API.example.com#Top', 'https://api.example.com/#Top'], + ['https://api.example.com/v2', 'https://api.example.com/v2'], + [ + 'https://User:Pass@API.example.com/Path', + 'https://user:pass@api.example.com/Path' + ] + ])('lowercases only scheme and authority of %s', (url, expected) => { + expect(normalizeUrlForMatching(url)).toBe(expected); + }); + + it.each([ + [ + 'https://api.example.com/public/../private', + 'https://api.example.com/private' + ], + [ + 'https://api.example.com/public/%2e%2e/private', + 'https://api.example.com/private' + ], + [ + 'https://api.example.com/public/%2E./private', + 'https://api.example.com/private' + ], + [ + 'https://api.example.com/v1/../v2/users', + 'https://api.example.com/v2/users' + ], + [ + 'https://api.example.com/a/./b/%2e/c', + 'https://api.example.com/a/b/c' + ], + ['https://api.example.com/a/..', 'https://api.example.com/'], + ['https://api.example.com/a/.', 'https://api.example.com/a/'], + ['https://api.example.com/../../a', 'https://api.example.com/a'], + [ + 'https://api.example.com\\Path\\To', + 'https://api.example.com/Path/To' + ], + [ + 'https://api.example.com/a/../b?x=/../y#/..', + 'https://api.example.com/b?x=/../y#/..' + ], + [ + 'https://api.example.com/v1.2/file.json', + 'https://api.example.com/v1.2/file.json' + ], + [ + 'https://api.example.com/a%20b/..c', + 'https://api.example.com/a%20b/..c' + ] + ])( + 'resolves dot segments and backslashes in the path of %s', + (url, expected) => { + expect(normalizeUrlForMatching(url)).toBe(expected); + } + ); + + it.each([ + ['a tab', '\t'], + ['a carriage return', '\r'], + ['a line feed', '\n'] + ])('removes %s before resolving dot segments', (_, char) => { + expect( + normalizeUrlForMatching( + `https://api.example.com/public/${char}../private` + ) + ).toBe('https://api.example.com/private'); + expect( + normalizeUrlForMatching(`https://api.exa${char}mple.com/v2`) + ).toBe('https://api.example.com/v2'); + }); + + it('trims leading and trailing control characters and spaces', () => { + expect( + normalizeUrlForMatching(' \u0000https://API.example.com/v2 \u001f') + ).toBe('https://api.example.com/v2'); + }); + + it.each([ + ['https://%61pi.example.com/v2', 'https://api.example.com/v2'], + ['https://API.%45xample.com/V2', 'https://api.example.com/V2'], + [ + 'https://api%2Eexample%2ecom:8443/x', + 'https://api.example.com:8443/x' + ], + [ + 'https://us%65r@%61pi.example.com/x', + 'https://us%65r@api.example.com/x' + ], + [ + 'https://api.example.com%2f.evil/x', + 'https://api.example.com%2f.evil/x' + ], + [ + 'https://api.example.com%40evil.test/x', + 'https://api.example.com%40evil.test/x' + ], + ['https://api.ex%zzample.com/x', 'https://api.ex%zzample.com/x'] + ])('decodes percent-encoded hostname characters in %s', (url, expected) => { + expect(normalizeUrlForMatching(url)).toBe(expected); + }); + + it.each([ + [ + 'https:///api.example.com/v2/users', + 'https://api.example.com/v2/users' + ], + ['https:////api.example.com/v2', 'https://api.example.com/v2'], + ['https:\\\\api.example.com/v2', 'https://api.example.com/v2'], + ['HTTPS:api.example.com/v2', 'https://api.example.com/v2'], + ['http:/api.example.com', 'http://api.example.com/'] + ])('collapses the slashes after the scheme of %s', (url, expected) => { + expect(normalizeUrlForMatching(url)).toBe(expected); + }); + + it.each([ + [ + 'https://api\uFF0Eexample.com/v2/users', + 'https://api.example.com/v2/users' + ], + [ + 'https://api%EF%BC%8Eexample.com/v2/users', + 'https://api.example.com/v2/users' + ], + ['https://api\u3002example\uFF61com/v2', 'https://api.example.com/v2'], + [ + 'https://\uFF41\uFF50\uFF49.example.com/v2', + 'https://api.example.com/v2' + ], + [ + 'https://api\uFF0Eexample.com:8443/v2', + 'https://api.example.com:8443/v2' + ], + ['https://[::1]:8080/v2', 'https://[::1]:8080/v2'] + ])('maps Unicode hostname equivalents in %s', (url, expected) => { + expect(normalizeUrlForMatching(url)).toBe(expected); + }); + + describe('normalizeHostname', () => { + it.each([ + ['API.Example.com', 'api.example.com'], + ['api\uFF0Eexample.com', 'api.example.com'], + ['%61pi.example.com', 'api.example.com'], + // Decoding must not introduce characters that change the host + ['api.example.com%2fevil', 'api.example.com%2fevil'], + ['api.example.com%E0%A4%A', 'api.example.com%e0%a4%a'] + ])('normalizes %s', (hostname, expected) => { + expect(normalizeHostname(hostname)).toBe(expected); + }); + }); + + it('returns URLs without a scheme unchanged', () => { + expect(normalizeUrlForMatching('/Relative/Path')).toBe( + '/Relative/Path' + ); + }); +}); 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..cfd27ae62 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/parseResponseHeaders.test.ts @@ -0,0 +1,114 @@ +/* + * 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'; + +describe('parseResponseHeaders', () => { + it('never stores headers rejected by the isAllowed filter', () => { + const raw = + 'set-cookie: session=secret\r\nauthorization: Bearer x\r\nx-datadog-trace-id: 1\r\ncache-control: no-cache\r\n'; + expect(parseResponseHeaders(raw, isHeaderAllowed)).toEqual({ + 'cache-control': 'no-cache' + }); + }); + + 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..6bc5583d6 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/sensitiveHeaderBlocklist.test.ts @@ -0,0 +1,112 @@ +/* + * 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', + 'dd-application-key', + 'x-application-key', + 'application_key', + // 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..928dfbec3 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/__tests__/tracingHeaderExclusion.test.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 { isTracingHeader } from '../tracingHeaderExclusion'; + +describe('isTracingHeader', () => { + describe('MUST EXCLUDE all SDK-injected 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', + 'x-datadog-tracked-by' + ]; + + 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..6c80255ad --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/captureHeaders.ts @@ -0,0 +1,437 @@ +/* + * 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'; + +import { isHeaderAllowed } from './isHeaderAllowed'; +import { normalizeUrlForMatching } from './normalizeUrlForMatching'; +import { parseResponseHeaders } from './parseResponseHeaders'; +import type { + CompiledHeaderCaptureConfig, + CompiledHeaderCaptureRule +} from './types'; + +const TAG = '[DatadogRUM][HeaderCapture]'; + +/** + * 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...in avoids allocating a keys array on the request hot path + // eslint-disable-next-line guard-for-in + for (const existing in accumulator) { + if (existing.toLowerCase() === lowered) { + delete accumulator[existing]; + break; + } + } + accumulator[headerName] = headerValue; + InternalLog.log( + `${TAG} Accumulated request header "${headerName}"`, + SdkVerbosity.DEBUG + ); + } else { + InternalLog.log( + `${TAG} Request header "${headerName}" blocked by security filter`, + SdkVerbosity.DEBUG + ); + } +}; + +/** + * 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; + } + + return filterResponseHeaders( + parseResponseHeaders(rawHeaders, isHeaderAllowed), + url, + config + ); +}; + +/** + * Filters already-parsed response headers (lowercased names) the same way + * as {@link captureResponseHeaders}. Used by request proxies whose response + * headers are not exposed as a raw string (e.g. Fetch `Headers`). + * + * @param allHeaders - Response headers keyed by lowercased name. + * @param url - The full request URL for rule matching. + * @param config - The compiled header capture configuration. + * @returns Filtered response headers, or undefined. + */ +export const filterResponseHeaders = ( + allHeaders: Record, + url: string, + config: CompiledHeaderCaptureConfig +): Record | undefined => { + if (config === null) { + return undefined; + } + + const allCount = Object.keys(allHeaders).length; + if (allCount === 0) { + InternalLog.log( + `${TAG} No response headers to capture for ${url}`, + SdkVerbosity.DEBUG + ); + return undefined; + } + + // Security filter first — defense in depth + const securityFiltered: Record = {}; + const blocked: string[] = []; + + for (const [name, value] of Object.entries(allHeaders)) { + if (isHeaderAllowed(name)) { + securityFiltered[name] = value; + } else { + blocked.push(name); + } + } + + if (blocked.length > 0) { + InternalLog.log( + `${TAG} Response headers blocked by security filter for ${url}: [${blocked.join( + ', ' + )}]`, + SdkVerbosity.DEBUG + ); + } + + if (Object.keys(securityFiltered).length === 0) { + return undefined; + } + + // Merge all matching rules' response header sets (union) + const matchUrl = normalizeUrlForMatching(url); + const allowedHeaders = mergeMatchingHeaderNames( + config, + matchUrl, + 'responseHeaderNames' + ); + + if (allowedHeaders.size === 0) { + InternalLog.log( + `${TAG} No rules matched ${url} for response headers — nothing captured`, + SdkVerbosity.DEBUG + ); + return undefined; + } + + const casingMap = mergeCasingMaps(config, matchUrl, 'responseHeaderCasing'); + const result = filterWithCasing( + securityFiltered, + allowedHeaders, + casingMap + ); + + if (result === undefined) { + InternalLog.log( + `${TAG} No response headers survived rule filtering for ${url}`, + SdkVerbosity.DEBUG + ); + } else { + InternalLog.log( + `${TAG} Captured ${ + Object.keys(result).length + }/${allCount} response headers for ${url}: [${Object.keys( + result + ).join(', ')}]`, + SdkVerbosity.DEBUG + ); + } + + return result; +}; + +/** + * 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; + } + + const accumulatedCount = Object.keys(headers).length; + + // Merge all matching rules' request header sets (union) + const matchUrl = normalizeUrlForMatching(url); + const allowedHeaders = mergeMatchingHeaderNames( + config, + matchUrl, + 'requestHeaderNames' + ); + + if (allowedHeaders.size === 0) { + InternalLog.log( + `${TAG} No rules matched ${url} for request headers — nothing captured`, + SdkVerbosity.DEBUG + ); + return undefined; + } + + const casingMap = mergeCasingMaps(config, matchUrl, 'requestHeaderCasing'); + const result = filterWithCasing(headers, allowedHeaders, casingMap); + + if (result === undefined) { + InternalLog.log( + `${TAG} No request headers survived rule filtering for ${url} (accumulated: ${accumulatedCount})`, + SdkVerbosity.DEBUG + ); + } else { + InternalLog.log( + `${TAG} Captured ${ + Object.keys(result).length + }/${accumulatedCount} request headers for ${url}: [${Object.keys( + result + ).join(', ')}]`, + SdkVerbosity.DEBUG + ); + } + + return result; +}; 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..78235004a --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/compileHeaderConfig.ts @@ -0,0 +1,339 @@ +/* + * 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 { normalizeHostname } from './normalizeUrlForMatching'; +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}` + * Optional userinfo (`user:pass@`) is allowed before the host; a hostname that + * only appears in the userinfo never matches. + * Subdomains are restricted to hostname characters, so a hostname appearing in + * the path or query of another URL never matches. Backslashes are excluded too, + * as URL parsers treat them as path separators. The path prefix (if any) must + * end on a path segment boundary: `api.example.com/v2` matches `/v2` and `/v2/users`, + * but not `/v22`. The host is matched case-insensitively (URLs go through + * normalizeUrlForMatching first), the path prefix case-sensitively. + * + * 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); + + // The pattern host goes through the same normalizeHostname as request URLs + // (normalizeUrlForMatching), so hosts match case-insensitively. Paths stay case-sensitive. + const escapedHost = escapeRegExp(normalizeHostname(hostname)); + // Host and path prefix must be followed by /, ?, # or end-of-string. + // A path prefix ending with '/' is already on a segment boundary. + const boundary = '(/|\\?|#|$)'; + const pathSuffix = pathPrefix.endsWith('/') + ? escapeRegExp(pathPrefix) + : `${escapeRegExp(pathPrefix)}${boundary}`; + + try { + // Regex matches full URL strings: scheme + optional userinfo + optional subdomains + host + optional port + path + const regex = new RegExp( + `^https?://([^/?#\\\\]*@)?([^/?#@:\\\\]+\\.)?${escapedHost}(:\\d+)?${pathSuffix}` + ); + // 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'; + } + + // Any array containing '*' matches all URLs, so it is equivalent to + // omitting forURLs (catch-all) + if (forURLs.includes('*')) { + 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('|')), isScoped: true }; + } catch (_e) { + return null; + } +}; + +const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every(item => typeof item === 'string'); + +/** + * Checks the shape of a user-supplied rule at runtime. Rules coming from + * file-based configuration are not type-checked, so malformed entries must + * be skipped instead of throwing during compilation. + */ +const getRuleValidationError = (rule: unknown): string | null => { + if (typeof rule !== 'object' || rule === null) { + return 'rule is not an object'; + } + const { type, headers, forURLs } = rule as Record; + if (forURLs !== undefined && !isStringArray(forURLs)) { + return '`forURLs` must be an array of strings'; + } + switch (type) { + case 'defaults': + return null; + case 'matchHeaders': + case 'matchRequestHeaders': + case 'matchResponseHeaders': + return isStringArray(headers) + ? null + : '`headers` must be an array of strings'; + default: + // Only interpolate strings: arbitrary objects may not be convertible + return typeof type === 'string' + ? `unknown type "${type}"` + : '`type` must be a string'; + } +}; + +/** + * 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: unknown[]): CompiledHeaderCaptureRule[] => { + const compiled: CompiledHeaderCaptureRule[] = []; + + for (const candidate of rules) { + const validationError = getRuleValidationError(candidate); + if (validationError !== null) { + InternalLog.log( + `[DatadogRUM] headerCaptureRules: Skipping invalid rule (${validationError}).`, + SdkVerbosity.WARN + ); + continue; + } + const rule = candidate as HeaderCaptureRule; + + // 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: + // Unknown types are rejected by getRuleValidationError + 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 == null) { + 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..c3c89a4c4 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/enforceSizeLimits.ts @@ -0,0 +1,208 @@ +/* + * 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'; +import { utf8ByteLength } from '../../../../utils/stringUtils'; + +const TAG = '[DatadogRUM][HeaderCapture]'; + +/** Maximum UTF-8 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 UTF-8 bytes across all header names and values. */ +export const MAX_TOTAL_BYTES = 2048; + +/** + * Truncates a string so its UTF-8 byte length does not exceed maxBytes. + * Single forward pass — O(n). Mirrors the surrogate-pair logic in utf8ByteLength. + */ +const truncateToBytes = (str: string, maxBytes: number): string => { + let bytes = 0; + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + let charBytes: number; + let advance = 1; + if (code < 0x80) { + charBytes = 1; + } else if (code < 0x800) { + charBytes = 2; + } else if (code >= 0xd800 && code <= 0xdbff) { + const next = i + 1 < str.length ? str.charCodeAt(i + 1) : 0; + if (next >= 0xdc00 && next <= 0xdfff) { + charBytes = 4; + advance = 2; + } else { + charBytes = 3; // lone high surrogate + } + } else if (code >= 0xdc00 && code <= 0xdfff) { + charBytes = 3; // lone low surrogate + } else { + charBytes = 3; + } + if (bytes + charBytes > maxBytes) { + return str.slice(0, i); + } + bytes += charBytes; + i += advance - 1; // -1 because the for-loop will also increment i + } + return str; +}; + +/** + * 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. All limits are in UTF-8 bytes. + * + * 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 with no stateful side effects (emits DEBUG logs when limits are hit). + */ +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 to MAX_HEADER_VALUE_BYTES (UTF-8 bytes) + const reqEntries = requestHeaders + ? Object.entries(requestHeaders).map(([name, value]): [ + string, + string + ] => { + const truncated = truncateToBytes(value, MAX_HEADER_VALUE_BYTES); + if (truncated.length < value.length) { + InternalLog.log( + `${TAG} Truncated request header "${name}" value from ${utf8ByteLength( + value + )} to ${MAX_HEADER_VALUE_BYTES} bytes`, + SdkVerbosity.DEBUG + ); + } + return [name, truncated]; + }) + : []; + + const resEntries = responseHeaders + ? Object.entries(responseHeaders).map(([name, value]): [ + string, + string + ] => { + const truncated = truncateToBytes(value, MAX_HEADER_VALUE_BYTES); + if (truncated.length < value.length) { + InternalLog.log( + `${TAG} Truncated response header "${name}" value from ${utf8ByteLength( + value + )} to ${MAX_HEADER_VALUE_BYTES} bytes`, + SdkVerbosity.DEBUG + ); + } + return [name, truncated]; + }) + : []; + + // 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); + InternalLog.log( + `${TAG} Header count cap hit (${totalCount} > ${MAX_HEADER_COUNT}): keeping ${cappedReqEntries.length} request + ${cappedResEntries.length} response headers`, + SdkVerbosity.DEBUG + ); + } + + // Step 4: Total byte budget -- drop from end (response first, then request) + let totalBytes = 0; + for (const [name, value] of cappedReqEntries) { + totalBytes += utf8ByteLength(name) + utf8ByteLength(value); + } + for (const [name, value] of cappedResEntries) { + totalBytes += utf8ByteLength(name) + utf8ByteLength(value); + } + + if (totalBytes > MAX_TOTAL_BYTES) { + InternalLog.log( + `${TAG} Total byte budget exceeded (${totalBytes} > ${MAX_TOTAL_BYTES}): dropping headers from end`, + SdkVerbosity.DEBUG + ); + // Drop response headers from end first + while (cappedResEntries.length > 0 && totalBytes > MAX_TOTAL_BYTES) { + const entry = cappedResEntries.pop(); + if (entry === undefined) { + break; + } + InternalLog.log( + `${TAG} Dropped response header "${entry[0]}" to fit byte budget`, + SdkVerbosity.DEBUG + ); + totalBytes -= utf8ByteLength(entry[0]) + utf8ByteLength(entry[1]); + } + // Then drop request headers from end + while (cappedReqEntries.length > 0 && totalBytes > MAX_TOTAL_BYTES) { + const entry = cappedReqEntries.pop(); + if (entry === undefined) { + break; + } + InternalLog.log( + `${TAG} Dropped request header "${entry[0]}" to fit byte budget`, + SdkVerbosity.DEBUG + ); + totalBytes -= utf8ByteLength(entry[0]) + utf8ByteLength(entry[1]); + } + } + + // 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/normalizeUrlForMatching.ts b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts new file mode 100644 index 000000000..c4b2b7a39 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/normalizeUrlForMatching.ts @@ -0,0 +1,181 @@ +/* + * 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. + */ + +// URL parsers ignore leading/trailing C0 control characters and spaces, and +// remove ASCII tabs and newlines anywhere in the URL. +// eslint-disable-next-line no-control-regex +const LEADING_TRAILING_C0_OR_SPACE = /^[\u0000-\u0020]+|[\u0000-\u0020]+$/g; +const TAB_OR_NEWLINE = /[\t\n\r]/g; + +// HTTP(S) URL parsers accept any number of slashes (or backslashes) after the scheme +const HTTP_SCHEME_AND_SLASHES = /^(https?):[/\\]*/i; + +const ENCODED_HOST_CHARACTER = /%([0-9a-fA-F]{2})/g; +const HOST_CHARACTER = /^[A-Za-z0-9.-]$/; +// Label separators that IDNA maps to '.' +const ALTERNATIVE_DOTS = /[\u3002\uFF0E\uFF61]/g; +// Characters that cannot appear in a hostname: decoding must never produce them, +// otherwise an encoded character could move the host boundary +// eslint-disable-next-line no-control-regex +const FORBIDDEN_HOST_CHARACTER = /[\u0000-\u0020/\\?#@:%<>[\]^|]/; + +const SINGLE_DOT_SEGMENTS = new Set(['.', '%2e']); +const DOUBLE_DOT_SEGMENTS = new Set(['..', '.%2e', '%2e.', '%2e%2e']); + +/** + * Normalizes a URL for `forURLs` scope matching, following the URL parsing + * semantics of HTTP clients: + * - leading/trailing control characters and spaces are trimmed, and tabs, + * CRs and LFs are removed; + * - extra slashes after `http(s):` are collapsed (`https:///host` -> `https://host`); + * - the scheme and authority are lowercased, and the hostname is normalized + * with {@link normalizeHostname}; + * - an omitted path is treated as `/`; + * - in the path, backslashes are treated as slashes and dot segments + * (`.`, `..`, and their `%2e` encodings) are resolved; + * - path case, query and fragment are left untouched. + * + * Example: `HTTPS://API.Example.com/Public/../Private?Q=1` -> `https://api.example.com/Private?Q=1` + */ +export const normalizeUrlForMatching = (rawUrl: string): string => { + const url = rawUrl + .replace(LEADING_TRAILING_C0_OR_SPACE, '') + .replace(TAB_OR_NEWLINE, '') + .replace(HTTP_SCHEME_AND_SLASHES, '$1://'); + const schemeEnd = url.indexOf('://'); + if (schemeEnd === -1) { + return url; + } + + const authorityEnd = findFirstOf(url, schemeEnd + 3, '/\\?#'); + const pathEnd = findFirstOf(url, authorityEnd, '?#'); + + return ( + normalizeAuthority(url.slice(0, authorityEnd)) + + normalizePath(url.slice(authorityEnd, pathEnd)) + + url.slice(pathEnd) + ); +}; + +/** + * Normalizes a hostname the way HTTP clients resolve it before connecting: + * percent-encodings are decoded, Unicode compatibility characters are mapped + * (NFKC, e.g. fullwidth letters), IDNA label separators (`。`, `.`, `。`) + * become `.`, and the result is lowercased. + * Used for both request URLs and configured `forURLs` hostnames. + * + * If decoding would produce a character that is not allowed in a hostname, + * only ASCII hostname characters are decoded. + */ +export const normalizeHostname = (hostname: string): string => { + if (/^[A-Za-z0-9.-]*$/.test(hostname)) { + return hostname.toLowerCase(); + } + + const normalized = mapUnicodeHostname(decodeUtf8(hostname)); + if (!FORBIDDEN_HOST_CHARACTER.test(normalized)) { + return normalized; + } + + return mapUnicodeHostname( + hostname.replace(ENCODED_HOST_CHARACTER, (encoded, hex: string) => { + const char = String.fromCharCode(parseInt(hex, 16)); + return HOST_CHARACTER.test(char) ? char : encoded; + }) + ); +}; + +const decodeUtf8 = (value: string): string => { + try { + return decodeURIComponent(value); + } catch (_e) { + // Malformed percent-encoding: leave it to the ASCII-only fallback + return value; + } +}; + +const mapUnicodeHostname = (hostname: string): string => { + let mapped = hostname; + try { + // Not available on every JS engine configuration + if (typeof mapped.normalize === 'function') { + mapped = mapped.normalize('NFKC'); + } + } catch (_e) { + // Keep the unmapped hostname + } + return mapped.replace(ALTERNATIVE_DOTS, '.').toLowerCase(); +}; + +/** + * Lowercases the scheme and authority and normalizes the hostname. + * Userinfo (before the last `@`) is lowercased but not decoded, and the port + * is kept as-is, so encoded characters can never move the host boundaries. + * IPv6 literals (`[...]`) are only lowercased. + */ +const normalizeAuthority = (schemeAndAuthority: string): string => { + const userinfoEnd = schemeAndAuthority.lastIndexOf('@'); + const hostStart = + userinfoEnd === -1 + ? schemeAndAuthority.indexOf('://') + 3 + : userinfoEnd + 1; + const hostAndPort = schemeAndAuthority.slice(hostStart); + const portMatch = /:\d*$/.exec(hostAndPort); + const host = portMatch + ? hostAndPort.slice(0, portMatch.index) + : hostAndPort; + const port = portMatch ? portMatch[0] : ''; + const normalizedHost = host.startsWith('[') + ? host.toLowerCase() + : normalizeHostname(host); + + return `${schemeAndAuthority + .slice(0, hostStart) + .toLowerCase()}${normalizedHost}${port}`; +}; + +const findFirstOf = (url: string, from: number, chars: string): number => { + for (let i = from; i < url.length; i++) { + if (chars.includes(url[i])) { + return i; + } + } + return url.length; +}; + +const normalizePath = (path: string): string => { + // HTTP requests without a path are sent to `/` + if (path === '') { + return '/'; + } + + // Fast path: nothing to resolve + if (!/[.\\%]/.test(path)) { + return path; + } + + const segments = path.replace(/\\/g, '/').slice(1).split('/'); + const output: string[] = []; + + segments.forEach((segment, index) => { + const isLast = index === segments.length - 1; + const lowered = segment.toLowerCase(); + if (DOUBLE_DOT_SEGMENTS.has(lowered)) { + output.pop(); + if (isLast) { + output.push(''); + } + } else if (SINGLE_DOT_SEGMENTS.has(lowered)) { + if (isLast) { + output.push(''); + } + } else { + output.push(segment); + } + }); + + return `/${output.join('/')}`; +}; 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..4b3792e0c --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/parseResponseHeaders.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. + */ + +/** + * 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 + * - Headers rejected by `isAllowed` (if provided) are never stored + */ +export const parseResponseHeaders = ( + rawHeaders: string | null | undefined, + isAllowed?: (headerName: string) => boolean +): 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, or the header is not allowed — skip + if (name === '' || (isAllowed !== undefined && !isAllowed(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..e10ea2757 --- /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|application).?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. + * + * Blocked headers are expected behavior, so they never produce WARN or ERROR + * logs; the capture pipeline only reports them at DEBUG verbosity. + * + * @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..a85c80f65 --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/headerCapture/tracingHeaderExclusion.ts @@ -0,0 +1,61 @@ +/* + * 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, + TRACKED_BY_HEADER_KEY +} from '../distributedTracing/headers'; + +/** + * Set of all SDK-injected distributed tracing (and tracking) 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, + TRACKED_BY_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/FetchProxy/FetchProxy.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts index 670717881..79b09d009 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/FetchProxy.ts @@ -12,6 +12,11 @@ import { callOriginalFetch } from '../common/FetchProxyState'; import type { RequestContext } from '../common/RequestContext'; import { createRequestContext, getResourceKey } from '../common/RequestContext'; import { ResourceReporter } from '../common/ResourceReporter'; +import { + fetchHeadersToRecord, + finalizeCapturedHeaders, + recordRequestHeader +} from '../common/headerCapture'; import { filterDevResource } from '../common/internalDevResourceBlocklist'; import { getInstrumentationHeaders, @@ -202,7 +207,9 @@ const buildHeaders = ({ originalHeaders.forEach(({ header }) => headers.delete(header)); originalHeaders.forEach(({ header, value }) => { - applyHeader({ headers, context, header, value }); + if (applyHeader({ headers, context, header, value })) { + recordRequestHeader(context.headerCapture, header, value); + } }); getInstrumentationHeaders(context).forEach(({ header, value }) => { applyHeader({ headers, context, header, value }); @@ -221,11 +228,13 @@ const applyHeader = ({ context: RequestContext; header: string; value: string; -}) => { +}): boolean => { const processedHeader = processRequestHeader({ context, header, value }); if (processedHeader.type === 'send') { headers.set(processedHeader.header, processedHeader.value); + return true; } + return false; }; const reportFetch = async ({ @@ -237,6 +246,15 @@ const reportFetch = async ({ response: Response; resourceReporter: ResourceReporter; }): Promise => { + // Snapshot before any await: some Fetch implementations expose mutable + // response headers that the app could change while the body is parsed. + const capturedHeaders = finalizeCapturedHeaders({ + state: context.headerCapture, + url: context.url, + statusCode: response.status, + responseHeaders: () => fetchHeadersToRecord(response.headers) + }); + // Only extract GraphQL errors if operationType is set AND error tracking is enabled if (context.graphql.operationType && context.graphql.trackErrors) { try { @@ -267,6 +285,7 @@ const reportFetch = async ({ }, graphqlAttributes: context.graphql, tracingAttributes: context.tracingAttributes, + ...capturedHeaders, response: { statusCode: response.status, size: getResponseSize(response) diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts index 10e37fe16..0027f944c 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/FetchProxy/__tests__/FetchProxy.test.ts @@ -16,6 +16,7 @@ import { DATADOG_GRAPH_QL_ERROR_HEADER, DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER } from '../../../graphql/graphqlHeaders'; +import { compileHeaderCaptureConfig } from '../../../headerCapture/compileHeaderConfig'; import type { ResourceReporter } from '../../common/ResourceReporter'; import type { RUMResource } from '../../interfaces/RumResource'; import { FetchProxy } from '../FetchProxy'; @@ -419,4 +420,277 @@ describe('FetchProxy', () => { expect(resource.graphqlAttributes?.errors).toBeUndefined(); }); }); + + describe('header capture', () => { + const getHeaderCaptureOptions = ( + rules: Parameters[0] + ) => ({ + ...getOptions(), + headerCaptureConfig: compileHeaderCaptureConfig(rules) + }); + + it('does not report headers when header capture is disabled', async () => { + const originalFetch = jest + .fn() + .mockResolvedValue( + createResponse({ headers: { 'cache-control': 'no-cache' } }) + ) as jest.MockedFunction; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart(getOptions()); + + await fetchGlobal.fetch('https://api.example.com/users', { + headers: { 'Content-Type': 'application/json' } + }); + await flushPromises(); + + const resource = reportResource.mock.calls[0][0] as RUMResource; + expect(resource.capturedRequestHeaders).toBeUndefined(); + expect(resource.capturedResponseHeaders).toBeUndefined(); + }); + + it('captures default request and response headers', async () => { + const originalFetch = jest.fn().mockResolvedValue( + createResponse({ + headers: { + 'cache-control': 'max-age=60', + etag: '"abc"', + 'set-cookie': 'session=secret', + 'x-custom': 'value' + } + }) + ) as jest.MockedFunction; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart(getHeaderCaptureOptions('defaults')); + + await fetchGlobal.fetch('https://api.example.com/users', { + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer secret', + 'X-Custom': 'value' + } + }); + await flushPromises(); + + const resource = reportResource.mock.calls[0][0] as RUMResource; + expect(resource.capturedRequestHeaders).toEqual({ + 'Content-Type': 'application/json' + }); + expect(resource.capturedResponseHeaders).toEqual({ + 'Cache-Control': 'max-age=60', + ETag: '"abc"' + }); + }); + + it('does not capture headers injected by the SDK', async () => { + const originalFetch = jest + .fn() + .mockResolvedValue(createResponse()) as jest.MockedFunction< + typeof fetch + >; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart( + getHeaderCaptureOptions([ + { + type: 'matchRequestHeaders', + headers: [ + SAMPLING_PRIORITY_HEADER_KEY, + PARENT_ID_HEADER_KEY, + TRACKED_BY_HEADER_KEY, + DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER, + 'x-request-id' + ] + } + ]) + ); + + await fetchGlobal.fetch('https://api.example.com/graphql', { + headers: { + [DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER]: 'query', + 'X-Request-Id': 'abc' + } + }); + await flushPromises(); + + // Fetch Headers lowercase names, so the casing comes from the rule + const resource = reportResource.mock.calls[0][0] as RUMResource; + expect(resource.capturedRequestHeaders).toEqual({ + 'x-request-id': 'abc' + }); + }); + + it('captures headers set on a Request-like input', async () => { + const originalFetch = jest + .fn() + .mockResolvedValue( + createResponse({ headers: { 'x-cache': 'HIT' } }) + ) as jest.MockedFunction; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart(getHeaderCaptureOptions('defaults')); + + await fetchGlobal.fetch(({ + url: 'https://api.example.com/users', + method: 'GET', + headers: new HeadersMock({ 'cache-control': 'no-cache' }) + } as unknown) as Request); + await flushPromises(); + + const resource = reportResource.mock.calls[0][0] as RUMResource; + expect(resource.capturedRequestHeaders).toEqual({ + 'Cache-Control': 'no-cache' + }); + expect(resource.capturedResponseHeaders).toEqual({ + 'X-Cache': 'HIT' + }); + }); + + it('applies URL-scoped rules', async () => { + const originalFetch = jest + .fn() + .mockResolvedValue( + createResponse({ headers: { 'x-request-id': 'abc' } }) + ) as jest.MockedFunction; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart( + getHeaderCaptureOptions([ + { + type: 'matchResponseHeaders', + headers: ['x-request-id'], + forURLs: ['api.example.com'] + } + ]) + ); + + await fetchGlobal.fetch('https://api.example.com/users'); + await fetchGlobal.fetch('https://other.example.org/users'); + await flushPromises(); + + const [matched, unmatched] = reportResource.mock.calls.map( + call => call[0] as RUMResource + ); + expect(matched.capturedResponseHeaders).toEqual({ + 'x-request-id': 'abc' + }); + expect(unmatched.capturedResponseHeaders).toBeUndefined(); + }); + + it('does not report headers for failed requests', async () => { + const originalFetch = jest + .fn() + .mockRejectedValue( + new Error('network failed') + ) as jest.MockedFunction; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart(getHeaderCaptureOptions('defaults')); + + await expect( + fetchGlobal.fetch('https://api.example.com/users', { + headers: { 'Content-Type': 'application/json' } + }) + ).rejects.toThrow('network failed'); + + const resource = reportResource.mock.calls[0][0] as RUMResource; + expect(resource.capturedRequestHeaders).toBeUndefined(); + expect(resource.capturedResponseHeaders).toBeUndefined(); + }); + + it('captures response headers before the GraphQL body is parsed', async () => { + const headers = new HeadersMock({ 'cache-control': 'max-age=60' }); + let resolveBody: (body: unknown) => void = () => {}; + const response = ({ + status: 200, + headers, + clone() { + return response; + }, + json: () => + new Promise(resolve => { + resolveBody = resolve; + }) + } as unknown) as Response; + const originalFetch = jest + .fn() + .mockResolvedValue(response) as jest.MockedFunction< + typeof fetch + >; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart(getHeaderCaptureOptions('defaults')); + + await fetchGlobal.fetch('https://api.example.com/graphql', { + headers: { + [DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER]: 'query', + [DATADOG_GRAPH_QL_ERROR_HEADER]: 'true' + } + }); + // The app mutates the response headers while the body is parsed + headers.delete('cache-control'); + headers.set('etag', '"later"'); + resolveBody({ data: {} }); + await flushPromises(); + + const resource = reportResource.mock.calls[0][0] as RUMResource; + expect(resource.capturedResponseHeaders).toEqual({ + 'Cache-Control': 'max-age=60' + }); + }); + + it('still reports request headers when response headers cannot be iterated', async () => { + const response = ({ + status: 200, + headers: { get: () => null }, + clone() { + return response; + } + } as unknown) as Response; + const originalFetch = jest + .fn() + .mockResolvedValue(response) as jest.MockedFunction< + typeof fetch + >; + const reportResource = jest.fn(); + const { fetchGlobal, proxy } = createProxy({ + originalFetch, + reportResource + }); + proxy.onTrackingStart(getHeaderCaptureOptions('defaults')); + + await fetchGlobal.fetch('https://api.example.com/users', { + headers: { 'Content-Type': 'application/json' } + }); + await flushPromises(); + + const resource = reportResource.mock.calls[0][0] as RUMResource; + expect(resource.capturedRequestHeaders).toEqual({ + 'Content-Type': 'application/json' + }); + expect(resource.capturedResponseHeaders).toBeUndefined(); + }); + }); }); 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 194944986..d88aecd05 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 54a67e774..8d8429264 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/XHRProxy.ts @@ -7,10 +7,16 @@ import { InternalLog } from '../../../../../InternalLog'; import { SdkVerbosity } from '../../../../../config/types'; import { extractGraphQLErrors } from '../../graphql/graphqlUtils'; +import { isHeaderAllowed } from '../../headerCapture/isHeaderAllowed'; +import { parseResponseHeaders } from '../../headerCapture/parseResponseHeaders'; import { isRunningWithinFetchProxy } from '../common/FetchProxyState'; import type { RequestContext } from '../common/RequestContext'; import { createRequestContext, getResourceKey } from '../common/RequestContext'; import { ResourceReporter } from '../common/ResourceReporter'; +import { + finalizeCapturedHeaders, + recordRequestHeader +} from '../common/headerCapture'; import { filterDevResource } from '../common/internalDevResourceBlocklist'; import { getInstrumentationHeaders, @@ -191,8 +197,23 @@ const reportXhr = async ( const key = getResourceKey(context); + // Stop before header processing so it is not counted in the resource duration context.timer.stop(); + // Snapshot before any await: the app may reopen this XHR from its + // completion handler while the GraphQL body is being read. + const statusCode = xhrProxy.status; + const capturedHeaders = finalizeCapturedHeaders({ + state: context.headerCapture, + url: context.url, + statusCode, + responseHeaders: () => + parseResponseHeaders( + xhrProxy.getAllResponseHeaders(), + isHeaderAllowed + ) + }); + // Only extract GraphQL errors if operationType is set AND error tracking is enabled if (context.graphql.operationType && context.graphql.trackErrors) { try { @@ -223,8 +244,9 @@ const reportXhr = async ( }, graphqlAttributes: context.graphql, tracingAttributes: context.tracingAttributes, + ...capturedHeaders, response: { - statusCode: xhrProxy.status, + statusCode, size: responseSize }, timings: { @@ -251,17 +273,26 @@ const proxySetRequestHeader = (providers: XHRProxyProviders): void => { return originalXhrSetRequestHeader.apply(this, [header, value]); } + // XHR coerces values to strings: do it once so the sent and the + // captured values are identical + const stringValue = String(value); const processedHeader = processRequestHeader({ context: this._datadog_xhr, header, - value + value: stringValue }); if (processedHeader.type === 'send') { - return originalXhrSetRequestHeader.apply(this, [ + const result = originalXhrSetRequestHeader.apply(this, [ processedHeader.header, processedHeader.value ]); + recordRequestHeader( + this._datadog_xhr.headerCapture, + header, + stringValue + ); + return result; } }; }; 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 c8b5bd7dc..343bf7a43 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 @@ -10,6 +10,7 @@ import { Platform, NativeModules } from 'react-native'; import { InternalLog } from '../../../../../../InternalLog'; import { SdkVerbosity } from '../../../../../../config/types'; import { BufferSingleton } from '../../../../../../sdk/DatadogProvider/Buffer/BufferSingleton'; +import { Timer } from '../../../../../../utils/Timer'; import { DdRum } from '../../../../../DdRum'; import { setCachedSessionId, @@ -43,6 +44,7 @@ import { DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER, DATADOG_GRAPH_QL_VARIABLES_HEADER } from '../../../graphql/graphqlHeaders'; +import { compileHeaderCaptureConfig } from '../../../headerCapture/compileHeaderConfig'; import { ResourceReporter } from '../../common/ResourceReporter'; import { XHRProxy } from '../XHRProxy'; import { @@ -71,6 +73,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(); @@ -2337,7 +2375,7 @@ describe('XHRProxy', () => { // THEN - Application successfully read the response expect(applicationReadResponse).toBe(responseBody); - const parsedByApp = JSON.parse(applicationReadResponse!); + const parsedByApp = JSON.parse(applicationReadResponse ?? ''); expect(parsedByApp.errors).toHaveLength(1); expect(parsedByApp.errors[0].message).toBe('User not found'); @@ -2551,7 +2589,7 @@ describe('XHRProxy', () => { // THEN - Application got complete response with all error details expect(applicationReadResponse).toBe(responseBody); - const parsedByApp = JSON.parse(applicationReadResponse!); + const parsedByApp = JSON.parse(applicationReadResponse ?? ''); expect(parsedByApp.errors).toHaveLength(2); expect(parsedByApp.errors[0].extensions.details).toBe( 'filtered' @@ -2580,4 +2618,588 @@ describe('XHRProxy', () => { }); }); }); + + describe('header capture', () => { + it('disabled mode: does not report 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( + DdNativeRum.stopResource.mock.calls[0][4]['_dd.request_headers'] + ).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( + DdNativeRum.stopResource.mock.calls[0][4][ + '_dd.response_headers' + ] + ).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( + DdNativeRum.stopResource.mock.calls[0][4]['_dd.request_headers'] + ).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( + DdNativeRum.stopResource.mock.calls[0][4][ + '_dd.response_headers' + ] + ).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 header capture is skipped + expect( + DdNativeRum.stopResource.mock.calls[0][4][ + '_dd.response_headers' + ] + ).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( + DdNativeRum.stopResource.mock.calls[0][4]['_dd.request_headers'] + ).toEqual({ + 'X-Request-Id': 'abc' + }); + expect( + DdNativeRum.stopResource.mock.calls[0][4][ + '_dd.response_headers' + ] + ).toEqual({ + etag: 'abc123', + 'x-cache': 'HIT' + }); + }); + + it('security: sensitive request headers are never reported', 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( + DdNativeRum.stopResource.mock.calls[0][4]['_dd.request_headers'] + ).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( + DdNativeRum.stopResource.mock.calls[0][4][ + '_dd.response_headers' + ] + ).toEqual({ + 'cache-control': 'no-cache' + }); + }); + }); + + describe('header capture with request reuse', () => { + it('reports the headers of the completed request when the XHR is reused during GraphQL body extraction', async () => { + // GIVEN + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: compileHeaderCaptureConfig('defaults') + }); + + const xhr = new XMLHttpRequestMock(); + xhr.open('POST', 'https://api.example.com/graphql'); + xhr.setRequestHeader( + DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER, + 'query' + ); + xhr.setRequestHeader(DATADOG_GRAPH_QL_ERROR_HEADER, 'true'); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'cache-control: max-age=60\r\n' + ); + const body = JSON.stringify({ errors: [{ message: 'boom' }] }); + xhr.responseText = body; + + // WHEN the request completes, then the app reuses the XHR + // before the GraphQL body has been read + xhr.complete(200, body); + xhr.status = 0; + xhr.getAllResponseHeaders.mockReturnValue('etag: "other"\r\n'); + await flushPromises(); + + // THEN + const [ + , + statusCode, + , + , + context + ] = DdNativeRum.stopResource.mock.calls[0]; + expect(statusCode).toBe(200); + expect(context['_dd.request_headers']).toEqual({ + 'Content-Type': 'application/json' + }); + expect(context['_dd.response_headers']).toEqual({ + 'Cache-Control': 'max-age=60' + }); + }); + + it('stringifies non-string request header values and still reports the resource', async () => { + // GIVEN + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: compileHeaderCaptureConfig([ + { + type: 'matchRequestHeaders', + headers: ['x-count', 'x-empty'] + } + ]) + }); + + // WHEN untyped callers pass non-string values + const xhr = new XMLHttpRequestMock(); + xhr.open('GET', 'https://api.example.com/v2/user'); + xhr.setRequestHeader('x-count', (42 as unknown) as string); + xhr.setRequestHeader('x-empty', (null as unknown) as string); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN the same string is sent and captured + expect(xhr.requestHeaders.get('x-count')).toBe('42'); + expect(xhr.requestHeaders.get('x-empty')).toBe('null'); + expect(DdNativeRum.stopResource).toHaveBeenCalledTimes(1); + expect( + DdNativeRum.stopResource.mock.calls[0][4]['_dd.request_headers'] + ).toEqual({ 'x-count': '42', 'x-empty': 'null' }); + }); + + it('stops the resource timer before processing captured headers', async () => { + // GIVEN + const timerStop = jest.spyOn(Timer.prototype, 'stop'); + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: compileHeaderCaptureConfig('defaults') + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open('GET', 'https://api.example.com/v2/user'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.getAllResponseHeaders.mockReturnValue( + 'cache-control: no-cache\r\n' + ); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN + expect(timerStop).toHaveBeenCalledTimes(1); + expect(xhr.getAllResponseHeaders).toHaveBeenCalled(); + expect(timerStop.mock.invocationCallOrder[0]).toBeLessThan( + xhr.getAllResponseHeaders.mock.invocationCallOrder[0] + ); + timerStop.mockRestore(); + }); + + it('does not capture the SDK tracked-by header even when a rule names it', async () => { + // GIVEN + xhrProxy.onTrackingStart({ + tracingSamplingRate: 100, + firstPartyHostsRegexMap: firstPartyHostsRegexMapBuilder([]), + headerCaptureConfig: compileHeaderCaptureConfig([ + { + type: 'matchRequestHeaders', + headers: [TRACKED_BY_HEADER_KEY, 'x-request-id'] + } + ]) + }); + + // WHEN + const xhr = new XMLHttpRequestMock(); + xhr.open('GET', 'https://api.example.com/v2/user'); + xhr.setRequestHeader('X-Request-Id', 'abc'); + xhr.send(); + xhr.notifyResponseArrived(); + xhr.complete(200, 'ok'); + await flushPromises(); + + // THEN the header was sent but not captured + expect(xhr.requestHeaders.get(TRACKED_BY_HEADER_KEY)).toBe( + TRACKED_BY_HEADER_VALUE + ); + expect( + DdNativeRum.stopResource.mock.calls[0][4]['_dd.request_headers'] + ).toEqual({ 'X-Request-Id': 'abc' }); + }); + }); + + 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/XHRProxy/baggageHeaderUtils.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/baggageHeaderUtils.ts index 540c68455..a5657ba3c 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/baggageHeaderUtils.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/XHRProxy/baggageHeaderUtils.ts @@ -6,6 +6,7 @@ import { InternalLog } from '../../../../../InternalLog'; import { SdkVerbosity } from '../../../../../config/types/SdkVerbosity'; +import { utf8ByteLength } from '../../../../../utils/stringUtils'; import { DD_RUM_ACCOUNT_ID_TAG, DD_RUM_SESSION_ID_TAG, @@ -138,40 +139,6 @@ export function formatBaggageHeader(entries: Set): string | null { return headerValue; } -/** - * Returns the number of bytes needed to encode a string in UTF-8. - * - * Useful as a lightweight alternative to Node.js `Buffer.byteLength()` - * for older environments that do not support it. - * - * @param text - The input string. - * @returns The UTF-8 byte length of the string. - */ -function utf8ByteLength(text: string): number { - let byteLength = text.length; - for (let i = text.length - 1; i >= 0; i--) { - const code = text.charCodeAt(i); - - // 2-byte characters (U+0080 to U+07FF) - if (code > 0x7f && code <= 0x7ff) { - byteLength++; - } - // 3-byte characters (U+0800 to U+FFFF) - else if (code > 0x7ff && code <= 0xffff) { - byteLength += 2; - } - - // Handle surrogate pairs (4-byte characters, e.g. emoji) - // These characters already count as 2 in the initial length - // Encountering the low surrogate already accounts for the full 4 bytes - // (2 from the initial length + 2 for the 3-byte characters logic above) - if (code >= 0xdc00 && code <= 0xdfff) { - i--; // prevents double counting the same character by skipping high surrogate - } - } - return byteLength; -} - /** * Returns a set of valid baggage header characters. */ diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/RequestContext.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/RequestContext.ts index c292c7f8f..9a8bdb675 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/RequestContext.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/RequestContext.ts @@ -16,6 +16,9 @@ import { URLHostParser } from '../XHRProxy/URLHostParser'; import type { RequestProxyOptions } from '../interfaces/RequestProxy'; import type { DdRumResourceGraphqlAttributes } from '../interfaces/RumResource'; +import type { HeaderCaptureState } from './headerCapture'; +import { createHeaderCaptureState } from './headerCapture'; + export interface RequestContext { resourceId: number; graphql: DdRumResourceGraphqlAttributes & { @@ -26,6 +29,7 @@ export interface RequestContext { timer: Timer; tracingAttributes: DdRumResourceTracingAttributes; baggageHeaderEntries: Set; + headerCapture: HeaderCaptureState; } // Disambiguates resources sharing the same start millisecond and method, @@ -55,7 +59,8 @@ export const createRequestContext = ({ userId: getCachedUserId(), accountId: getCachedAccountId() }), - baggageHeaderEntries: new Set() + baggageHeaderEntries: new Set(), + headerCapture: createHeaderCaptureState(options.headerCaptureConfig) }; }; diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/ResourceReporter.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/ResourceReporter.ts index ba0f2f293..f44aade6a 100644 --- a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/ResourceReporter.ts +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/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 = {}; @@ -86,6 +88,13 @@ const formatResourceStopContext = ( } } + if (capturedRequestHeaders !== undefined) { + attributes['_dd.request_headers'] = capturedRequestHeaders; + } + if (capturedResponseHeaders !== undefined) { + attributes['_dd.response_headers'] = capturedResponseHeaders; + } + return attributes; }; @@ -103,7 +112,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/common/__tests__/headerCapture.test.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/__tests__/headerCapture.test.ts new file mode 100644 index 000000000..d9edc720d --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/__tests__/headerCapture.test.ts @@ -0,0 +1,164 @@ +/* + * 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 { DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER } from '../../../graphql/graphqlHeaders'; +import { compileHeaderCaptureConfig } from '../../../headerCapture/compileHeaderConfig'; +import { + createHeaderCaptureState, + fetchHeadersToRecord, + finalizeCapturedHeaders, + recordRequestHeader +} from '../headerCapture'; + +jest.mock('../../../../../../InternalLog'); + +const URL = 'https://api.example.com/users'; + +describe('headerCapture', () => { + describe('createHeaderCaptureState', () => { + it('does not accumulate request headers when capture is disabled', () => { + const state = createHeaderCaptureState(null); + recordRequestHeader(state, 'Content-Type', 'application/json'); + + expect(state.requestHeaders).toBeUndefined(); + }); + + it('treats a missing config as disabled', () => { + expect(createHeaderCaptureState(undefined)).toEqual({ + config: null, + requestHeaders: undefined + }); + }); + }); + + describe('recordRequestHeader', () => { + it('ignores Datadog internal and sensitive headers', () => { + const state = createHeaderCaptureState( + compileHeaderCaptureConfig('defaults') + ); + recordRequestHeader( + state, + DATADOG_GRAPH_QL_OPERATION_TYPE_HEADER, + 'query' + ); + recordRequestHeader(state, 'Authorization', 'Bearer secret'); + recordRequestHeader(state, 'Content-Type', 'application/json'); + + expect(state.requestHeaders).toEqual({ + 'Content-Type': 'application/json' + }); + }); + }); + + describe('finalizeCapturedHeaders', () => { + it('returns no headers when capture is disabled', () => { + const responseHeaders = jest.fn(() => ({ etag: 'abc' })); + + expect( + finalizeCapturedHeaders({ + state: createHeaderCaptureState(null), + url: URL, + statusCode: 200, + responseHeaders + }) + ).toEqual({}); + expect(responseHeaders).not.toHaveBeenCalled(); + }); + + it('returns no headers for status 0 requests', () => { + const state = createHeaderCaptureState( + compileHeaderCaptureConfig('defaults') + ); + recordRequestHeader(state, 'Content-Type', 'application/json'); + + expect( + finalizeCapturedHeaders({ + state, + url: URL, + statusCode: 0, + responseHeaders: () => ({ etag: 'abc' }) + }) + ).toEqual({}); + }); + + it.each([200, 0])( + 'releases the request header store (status %s)', + statusCode => { + const state = createHeaderCaptureState( + compileHeaderCaptureConfig('defaults') + ); + recordRequestHeader(state, 'Content-Type', 'application/json'); + + finalizeCapturedHeaders({ + state, + url: URL, + statusCode, + responseHeaders: () => ({}) + }); + + expect(state.requestHeaders).toBeUndefined(); + } + ); + + it('filters by rules and enforces size limits', () => { + const state = createHeaderCaptureState( + compileHeaderCaptureConfig('defaults') + ); + recordRequestHeader(state, 'Content-Type', 'application/json'); + recordRequestHeader(state, 'X-Custom', 'value'); + + const result = finalizeCapturedHeaders({ + state, + url: URL, + statusCode: 200, + responseHeaders: () => ({ + etag: 'x'.repeat(500), + 'x-custom': 'value' + }) + }); + + expect(result.capturedRequestHeaders).toEqual({ + 'Content-Type': 'application/json' + }); + expect(result.capturedResponseHeaders?.ETag).toHaveLength(128); + expect( + result.capturedResponseHeaders?.['x-custom'] + ).toBeUndefined(); + }); + }); + + describe('fetchHeadersToRecord', () => { + it('never copies blocked headers', () => { + expect( + fetchHeadersToRecord( + new Headers({ + 'Set-Cookie': 'session=secret', + Authorization: 'Bearer secret', + 'x-datadog-tracked-by': 'react-native', + 'Cache-Control': 'no-cache' + }) + ) + ).toEqual({ 'cache-control': 'no-cache' }); + }); + + it('lowercases header names', () => { + expect( + fetchHeadersToRecord( + new Headers({ 'Cache-Control': 'no-cache' }) + ) + ).toEqual({ 'cache-control': 'no-cache' }); + }); + + it('returns an empty record when headers cannot be iterated', () => { + expect(fetchHeadersToRecord(undefined)).toEqual({}); + expect( + fetchHeadersToRecord(({ + get: () => null + } as unknown) as Headers) + ).toEqual({}); + }); + }); +}); diff --git a/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/headerCapture.ts b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/headerCapture.ts new file mode 100644 index 000000000..b8ebcbc3a --- /dev/null +++ b/packages/core/src/rum/instrumentation/resourceTracking/requestProxy/common/headerCapture.ts @@ -0,0 +1,119 @@ +/* + * 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 { + accumulateRequestHeader, + filterRequestHeadersByMode, + filterResponseHeaders +} from '../../headerCapture/captureHeaders'; +import { enforceSizeLimits } from '../../headerCapture/enforceSizeLimits'; +import { isHeaderAllowed } from '../../headerCapture/isHeaderAllowed'; +import type { CompiledHeaderCaptureConfig } from '../../headerCapture/types'; +import { isDatadogCustomHeader } from '../../headers'; + +/** + * Per-request header capture state, shared by all request proxies. + */ +export interface HeaderCaptureState { + config: CompiledHeaderCaptureConfig; + /** `undefined` when header capture is disabled. */ + requestHeaders: Record | undefined; +} + +export type CapturedHeaders = { + capturedRequestHeaders?: Record; + capturedResponseHeaders?: Record; +}; + +export const createHeaderCaptureState = ( + config: CompiledHeaderCaptureConfig | undefined +): HeaderCaptureState => ({ + config: config ?? null, + requestHeaders: config != null ? {} : undefined +}); + +/** + * Records a user-set request header. Datadog internal headers are ignored, + * sensitive headers are dropped before being stored. + * Values are stringified the same way XHR and Fetch coerce them, since + * untyped callers can pass `null`, numbers, etc. + */ +export const recordRequestHeader = ( + state: HeaderCaptureState, + header: string, + value: string +): void => { + if ( + state.requestHeaders === undefined || + isDatadogCustomHeader(header.toLowerCase()) + ) { + return; + } + accumulateRequestHeader(state.requestHeaders, header, String(value)); +}; + +/** + * Applies rule filtering and size limits to the headers of a completed request. + * Nothing is reported for aborted or network-error requests (status 0). + * The request header store is released, so a retained request object does not + * keep unreported or untruncated header values alive. + * + * @param responseHeaders - Response headers keyed by lowercased name. + */ +export const finalizeCapturedHeaders = ({ + state, + url, + statusCode, + responseHeaders +}: { + state: HeaderCaptureState; + url: string; + statusCode: number; + responseHeaders: () => Record; +}): CapturedHeaders => { + const requestHeaders = state.requestHeaders; + state.requestHeaders = undefined; + + if ( + state.config === null || + requestHeaders === undefined || + statusCode === 0 + ) { + return {}; + } + + const limited = enforceSizeLimits( + filterRequestHeadersByMode(requestHeaders, url, state.config), + filterResponseHeaders(responseHeaders(), url, state.config) + ); + + return { + capturedRequestHeaders: limited.requestHeaders, + capturedResponseHeaders: limited.responseHeaders + }; +}; + +/** + * Converts a Fetch `Headers` object into a record keyed by lowercased name. + * Blocked headers (sensitive or SDK-injected) are never copied. + * Some Fetch implementations (e.g. older `expo/fetch`) may not implement + * `forEach`, in which case no headers are returned. + */ +export const fetchHeadersToRecord = ( + headers: Headers | null | undefined +): Record => { + const result: Record = {}; + if (!headers || typeof headers.forEach !== 'function') { + return result; + } + headers.forEach((value: string, name: string) => { + const lowered = name.toLowerCase(); + if (isHeaderAllowed(lowered)) { + result[lowered] = value; + } + }); + return result; +}; 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 37abc1eec..efaae51f5 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 0e4fd1030..7c8e1fc97 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 DdRumResourceGraphqlError = { diff --git a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx index 13ebe6b9e..92eecf523 100644 --- a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx +++ b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx @@ -75,7 +75,7 @@ describe('DatadogProvider', () => { "clientToken": "fakeToken", "configurationForTelemetry": { "initializationType": "SYNC", - "reactNativeVersion": "0.76.9", + "reactNativeVersion": "${reactNativeVersion}", "reactVersion": "18.3.1", "trackErrors": true, "trackInteractions": true, @@ -92,6 +92,7 @@ describe('DatadogProvider', () => { "customEndpoint": undefined, "errorEventMapper": null, "firstPartyHosts": [], + "headerCaptureRules": undefined, "initialResourceThreshold": undefined, "longTaskThresholdMs": 0, "nativeCrashReportEnabled": false, diff --git a/packages/core/src/utils/__tests__/stringUtils.test.ts b/packages/core/src/utils/__tests__/stringUtils.test.ts new file mode 100644 index 000000000..d7dd3eac9 --- /dev/null +++ b/packages/core/src/utils/__tests__/stringUtils.test.ts @@ -0,0 +1,90 @@ +/* + * 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 { utf8ByteLength } from '../stringUtils'; + +describe('utf8ByteLength', () => { + describe('ASCII strings (1 byte per char)', () => { + it('returns 0 for empty string', () => { + expect(utf8ByteLength('')).toBe(0); + }); + + it('counts single ASCII character as 1 byte', () => { + expect(utf8ByteLength('a')).toBe(1); + }); + + it('counts ASCII string correctly', () => { + expect(utf8ByteLength('hello')).toBe(5); + }); + }); + + describe('2-byte characters (U+0080 – U+07FF)', () => { + it('counts a 2-byte character correctly', () => { + // 'é' is U+00E9 → 2 bytes in UTF-8 + expect(utf8ByteLength('é')).toBe(2); + }); + + it('counts mixed ASCII and 2-byte characters', () => { + // 'café' → c(1) + a(1) + f(1) + é(2) = 5 + expect(utf8ByteLength('café')).toBe(5); + }); + }); + + describe('3-byte characters (U+0800 – U+FFFF, excluding surrogates)', () => { + it('counts a 3-byte CJK character correctly', () => { + // '中' is U+4E2D → 3 bytes in UTF-8 + expect(utf8ByteLength('中')).toBe(3); + }); + + it('counts a string of 3-byte characters correctly', () => { + // '中文' → 3 + 3 = 6 + expect(utf8ByteLength('中文')).toBe(6); + }); + }); + + describe('4-byte characters — valid surrogate pairs', () => { + it('counts a valid surrogate pair (emoji) as 4 bytes', () => { + // '😀' is U+1F600, encoded as surrogate pair 😀 + expect(utf8ByteLength('😀')).toBe(4); + }); + + it('counts multiple emoji correctly', () => { + expect(utf8ByteLength('😀😀')).toBe(8); + }); + + it('counts mixed ASCII and emoji', () => { + // 'a😀b' → 1 + 4 + 1 = 6 + expect(utf8ByteLength('a😀b')).toBe(6); + }); + }); + + describe('unpaired surrogates — malformed strings', () => { + it('counts a lone high surrogate as 3 bytes', () => { + // \uD83D is a high surrogate not followed by a low surrogate + const loneHigh = '\uD83D'; + expect(utf8ByteLength(loneHigh)).toBe(3); + }); + + it('counts a lone low surrogate as 3 bytes', () => { + // \uDE00 is a low surrogate not preceded by a high surrogate + const loneLow = '\uDE00'; + expect(utf8ByteLength(loneLow)).toBe(3); + }); + + it('does not under-count when a high surrogate is followed by a non-low-surrogate', () => { + // \uD83D (high surrogate) + 'a' (not a low surrogate) + // Should be 3 + 1 = 4, not 4 (treating as pair) + const highPlusAscii = '\uD83Da'; + expect(utf8ByteLength(highPlusAscii)).toBe(4); + }); + + it('counts two consecutive lone surrogates as 6 bytes', () => { + // \uD83D\uD83D — two high surrogates, each lone + const twoHighSurrogates = '\uD83D\uD83D'; + expect(utf8ByteLength(twoHighSurrogates)).toBe(6); + }); + }); +}); diff --git a/packages/core/src/utils/stringUtils.ts b/packages/core/src/utils/stringUtils.ts new file mode 100644 index 000000000..ed16b6160 --- /dev/null +++ b/packages/core/src/utils/stringUtils.ts @@ -0,0 +1,41 @@ +/* + * 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. + */ + +/** + * Returns the number of bytes needed to encode a string in UTF-8. + * + * Works in all JS environments (Hermes, JSC, Node.js) — no TextEncoder or + * deprecated APIs required. + * + * Handles surrogate pairs correctly: a valid high+low pair counts as 4 bytes. + * Lone surrogates (unpaired high or low) each count as 3 bytes, matching the + * WTF-8 / WHATWG encoding convention for malformed strings. + */ +export function utf8ByteLength(str: string): number { + let bytes = 0; + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if (code < 0x80) { + bytes += 1; + } else if (code < 0x800) { + bytes += 2; + } else if (code >= 0xd800 && code <= 0xdbff) { + // High surrogate — check for a following low surrogate + const next = i + 1 < str.length ? str.charCodeAt(i + 1) : 0; + if (next >= 0xdc00 && next <= 0xdfff) { + i += 1; // consume the low surrogate + bytes += 4; + } else { + bytes += 3; // lone high surrogate + } + } else if (code >= 0xdc00 && code <= 0xdfff) { + bytes += 3; // lone low surrogate + } else { + bytes += 3; + } + } + return bytes; +}