diff --git a/CHANGELOG.md b/CHANGELOG.md index e37292bbb0..e569bc673c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Features - Add `nativeStackAndroid` support to `NativeLinkedErrors`, capturing the JVM stack trace of rejected native module promises as a linked exception ([#6278](https://github.com/getsentry/sentry-react-native/pull/6278)) +- Record XHR request/response headers and (optionally) bodies in Mobile Session Replay. Opt in via `mobileReplayIntegration` with `networkDetailAllowUrls` to capture headers; set `networkCaptureBodies: true` to also capture bodies. Other options: `networkDetailDenyUrls`, `networkRequestHeaders`, `networkResponseHeaders`. Authorization-like headers are always stripped, bodies are capped at ~150 KB. Covers XHR-based clients like `axios`; fetch will follow. See [Network Details](https://docs.sentry.io/platforms/react-native/session-replay/#network-details) for details. ([#6288](https://github.com/getsentry/sentry-react-native/pull/6288)) - Warn during dev builds when multiple versions of Sentry JS SDK are detected ([#6269](https://github.com/getsentry/sentry-react-native/pull/6269)) ### Dependencies diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt index 71b2f315c8..2e4e52c6bb 100644 --- a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt +++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/rnsentryandroidtester/RNSentryReplayBreadcrumbConverterTest.kt @@ -4,8 +4,11 @@ import io.sentry.Breadcrumb import io.sentry.SentryLevel import io.sentry.react.RNSentryReplayBreadcrumbConverter import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebSpanEvent import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 @@ -247,6 +250,93 @@ class RNSentryReplayBreadcrumbConverterTest { assertEquals("label5(element5, file5) > label4(file4) > label3(element3) > label2", actual) } + @Test + fun convertNetworkBreadcrumbForwardsBodyAndHeadersAndStripsMeta() { + val converter = RNSentryReplayBreadcrumbConverter() + val testBreadcrumb = Breadcrumb() + testBreadcrumb.category = "xhr" + testBreadcrumb.setData("url", "https://api.example.com/users") + testBreadcrumb.setData("method", "POST") + testBreadcrumb.setData("start_timestamp", 1_000.0) + testBreadcrumb.setData("end_timestamp", 2_000.0) + testBreadcrumb.setData( + "request", + mapOf( + "body" to "{\"hello\":\"world\"}", + "headers" to mapOf("content-type" to "application/json"), + "_meta" to mapOf("warnings" to listOf("MAX_BODY_SIZE_EXCEEDED")), + ), + ) + testBreadcrumb.setData( + "response", + mapOf( + "body" to "[UNPARSEABLE_BODY_TYPE]", + "_meta" to mapOf("warnings" to listOf("UNPARSEABLE_BODY_TYPE")), + ), + ) + + val actual = converter.convertNetworkBreadcrumb(testBreadcrumb) as RRWebSpanEvent + val data = actual.data!! + + @Suppress("UNCHECKED_CAST") + val request = data["request"] as Map + assertEquals("{\"hello\":\"world\"}", request["body"]) + assertEquals(mapOf("content-type" to "application/json"), request["headers"]) + assertNull("_meta must be stripped before forwarding to native rrweb", request["_meta"]) + + @Suppress("UNCHECKED_CAST") + val response = data["response"] as Map + assertEquals("[UNPARSEABLE_BODY_TYPE]", response["body"]) + assertNull(response["_meta"]) + } + + @Test + fun convertNetworkBreadcrumbAcceptsNonDoubleNumberFields() { + val converter = RNSentryReplayBreadcrumbConverter() + val testBreadcrumb = Breadcrumb() + testBreadcrumb.category = "xhr" + testBreadcrumb.setData("url", "https://api.example.com/users") + // RN bridge may surface numeric breadcrumb data as Long/Integer rather than + // Double; the converter must accept all Number subtypes without crashing or + // silently dropping the field. + testBreadcrumb.setData("start_timestamp", 1_000L) + testBreadcrumb.setData("end_timestamp", 2_000) + testBreadcrumb.setData("status_code", 201L) + testBreadcrumb.setData("request_body_size", 42) + testBreadcrumb.setData("response_body_size", 100L) + + val actual = converter.convertNetworkBreadcrumb(testBreadcrumb) as RRWebSpanEvent + assertEquals(1.0, actual.startTimestamp, 0.001) + assertEquals(2.0, actual.endTimestamp, 0.001) + val data = actual.data!! + assertEquals(201, data["statusCode"]) + assertEquals(42.0, data["requestBodySize"]) + assertEquals(100.0, data["responseBodySize"]) + } + + @Test + fun convertNetworkBreadcrumbDropsSideThatIsEmptyAfterMetaStrip() { + val converter = RNSentryReplayBreadcrumbConverter() + val testBreadcrumb = Breadcrumb() + testBreadcrumb.category = "xhr" + testBreadcrumb.setData("url", "https://api.example.com/users") + testBreadcrumb.setData("start_timestamp", 1_000.0) + testBreadcrumb.setData("end_timestamp", 2_000.0) + // Request side contains only `_meta` — once stripped, nothing remains. + testBreadcrumb.setData( + "request", + mapOf("_meta" to mapOf("warnings" to listOf("UNPARSEABLE_BODY_TYPE"))), + ) + // Response side is not a map (or missing) — should also be dropped. + testBreadcrumb.setData("response", "not-a-map") + + val actual = converter.convertNetworkBreadcrumb(testBreadcrumb) as RRWebSpanEvent + val data = actual.data!! + + assertTrue("empty-after-strip request side must be omitted", !data.containsKey("request")) + assertTrue("non-map response side must be omitted", !data.containsKey("response")) + } + private fun assertRRWebBreadcrumbDefaults(actual: RRWebBreadcrumbEvent) { assertEquals("default", actual.breadcrumbType) assertEquals(actual.breadcrumbTimestamp * 1000, actual.timestamp.toDouble(), 0.05) diff --git a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift index 17906e1d67..3970c17085 100644 --- a/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift +++ b/packages/core/RNSentryCocoaTester/RNSentryCocoaTesterTests/RNSentryReplayBreadcrumbConverterTests.swift @@ -232,6 +232,72 @@ final class RNSentryReplayBreadcrumbConverterTests: XCTestCase { XCTAssertEqual(actual, "label5(element5, file5) > label4(file4) > label3(element3) > label2") } + func testConvertNetworkBreadcrumbForwardsBodyAndHeadersAndStripsMeta() { + let converter = RNSentryReplayBreadcrumbConverter() + let testBreadcrumb = Breadcrumb() + testBreadcrumb.timestamp = Date() + testBreadcrumb.category = "xhr" + testBreadcrumb.data = [ + "url": "https://api.example.com/users", + "method": "POST", + "start_timestamp": NSNumber(value: 1_000.0), + "end_timestamp": NSNumber(value: 2_000.0), + "request": [ + "body": "{\"hello\":\"world\"}", + "headers": ["content-type": "application/json"], + "_meta": ["warnings": ["MAX_BODY_SIZE_EXCEEDED"]] + ], + "response": [ + "body": "[UNPARSEABLE_BODY_TYPE]", + "_meta": ["warnings": ["UNPARSEABLE_BODY_TYPE"]] + ] + ] + + let actual = converter.convert(from: testBreadcrumb) + XCTAssertNotNil(actual) + let event = actual!.serialize() + let eventData = event["data"] as! [String: Any?] + let payload = eventData["payload"] as! [String: Any?] + let data = payload["data"] as! [String: Any?] + + let request = data["request"] as! [String: Any] + XCTAssertEqual("{\"hello\":\"world\"}", request["body"] as! String) + XCTAssertEqual(["content-type": "application/json"], request["headers"] as! [String: String]) + XCTAssertNil(request["_meta"], "_meta must be stripped before forwarding to native rrweb") + + let response = data["response"] as! [String: Any] + XCTAssertEqual("[UNPARSEABLE_BODY_TYPE]", response["body"] as! String) + XCTAssertNil(response["_meta"]) + } + + func testConvertNetworkBreadcrumbDropsSideThatIsEmptyAfterMetaStrip() { + let converter = RNSentryReplayBreadcrumbConverter() + let testBreadcrumb = Breadcrumb() + testBreadcrumb.timestamp = Date() + testBreadcrumb.category = "xhr" + testBreadcrumb.data = [ + "url": "https://api.example.com/users", + "start_timestamp": NSNumber(value: 1_000.0), + "end_timestamp": NSNumber(value: 2_000.0), + // Request side contains only `_meta` — once stripped, nothing remains. + "request": [ + "_meta": ["warnings": ["UNPARSEABLE_BODY_TYPE"]] + ], + // Response side is not a dict — should also be dropped. + "response": "not-a-dict" + ] + + let actual = converter.convert(from: testBreadcrumb) + XCTAssertNotNil(actual) + let event = actual!.serialize() + let eventData = event["data"] as! [String: Any?] + let payload = eventData["payload"] as! [String: Any?] + let data = payload["data"] as! [String: Any?] + + XCTAssertNil(data["request"] ?? nil, "empty-after-strip request side must be omitted") + XCTAssertNil(data["response"] ?? nil, "non-dict response side must be omitted") + } + private func assertRRWebBreadcrumbDefaults(actual: [String: Any?]) { let data = actual["data"] as! [String: Any?] let payload = data["payload"] as! [String: Any?] diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java index 9755c27617..b92f986f1d 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryReplayBreadcrumbConverter.java @@ -144,13 +144,16 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc @TestOnly public @Nullable RRWebEvent convertNetworkBreadcrumb(final @NotNull Breadcrumb breadcrumb) { + // Use Number.doubleValue() rather than a direct (Double) cast: the RN bridge can + // surface timestamps as Long/Integer, which pass `instanceof Number` but would + // throw `ClassCastException` on a direct cast to Double. final Double startTimestamp = breadcrumb.getData("start_timestamp") instanceof Number - ? (Double) breadcrumb.getData("start_timestamp") + ? ((Number) breadcrumb.getData("start_timestamp")).doubleValue() : null; final Double endTimestamp = breadcrumb.getData("end_timestamp") instanceof Number - ? (Double) breadcrumb.getData("end_timestamp") + ? ((Number) breadcrumb.getData("end_timestamp")).doubleValue() : null; final String url = breadcrumb.getData("url") instanceof String ? (String) breadcrumb.getData("url") : null; @@ -163,17 +166,26 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc if (breadcrumb.getData("method") instanceof String) { data.put("method", breadcrumb.getData("method")); } - if (breadcrumb.getData("status_code") instanceof Double) { - final Double statusCode = (Double) breadcrumb.getData("status_code"); + // Accept any Number subtype (Double/Long/Integer) — the RN bridge does not guarantee Double. + if (breadcrumb.getData("status_code") instanceof Number) { + final int statusCode = ((Number) breadcrumb.getData("status_code")).intValue(); if (statusCode > 0) { - data.put("statusCode", statusCode.intValue()); + data.put("statusCode", statusCode); } } - if (breadcrumb.getData("request_body_size") instanceof Double) { - data.put("requestBodySize", breadcrumb.getData("request_body_size")); + if (breadcrumb.getData("request_body_size") instanceof Number) { + data.put("requestBodySize", ((Number) breadcrumb.getData("request_body_size")).doubleValue()); } - if (breadcrumb.getData("response_body_size") instanceof Double) { - data.put("responseBodySize", breadcrumb.getData("response_body_size")); + if (breadcrumb.getData("response_body_size") instanceof Number) { + data.put("responseBodySize", ((Number) breadcrumb.getData("response_body_size")).doubleValue()); + } + final Map requestSide = sanitizeNetworkSide(breadcrumb.getData("request")); + if (!requestSide.isEmpty()) { + data.put("request", requestSide); + } + final Map responseSide = sanitizeNetworkSide(breadcrumb.getData("response")); + if (!responseSide.isEmpty()) { + data.put("response", responseSide); } final RRWebSpanEvent rrWebSpanEvent = new RRWebSpanEvent(); @@ -185,6 +197,21 @@ public final class RNSentryReplayBreadcrumbConverter extends DefaultReplayBreadc return rrWebSpanEvent; } + /** + * Copy a JS-emitted request/response side dict, dropping the JS-internal `_meta` warnings field + * so it does not leak into the native rrweb span event. Returns an empty map when the input is + * not a Map or has no remaining fields. + */ + private @NotNull Map sanitizeNetworkSide(final @Nullable Object raw) { + if (!(raw instanceof Map)) { + return new HashMap<>(); + } + final Map source = (Map) raw; + final Map out = new HashMap<>(source); + out.remove("_meta"); + return out; + } + private void setRRWebEventDefaultsFrom( final @NotNull RRWebBreadcrumbEvent rrWebBreadcrumb, final @NotNull Breadcrumb breadcrumb) { rrWebBreadcrumb.setLevel(breadcrumb.getLevel()); diff --git a/packages/core/ios/RNSentryReplayBreadcrumbConverter.m b/packages/core/ios/RNSentryReplayBreadcrumbConverter.m index 6bc5366481..d6d04b171d 100644 --- a/packages/core/ios/RNSentryReplayBreadcrumbConverter.m +++ b/packages/core/ios/RNSentryReplayBreadcrumbConverter.m @@ -179,6 +179,14 @@ + (NSString *_Nullable)getTouchPathMessageFrom:(NSArray *_Nullable)path if ([breadcrumb.data[@"response_body_size"] isKindOfClass:[NSNumber class]]) { data[@"responseBodySize"] = breadcrumb.data[@"response_body_size"]; } + NSDictionary *requestSide = [self sanitizeNetworkSide:breadcrumb.data[@"request"]]; + if (requestSide != nil) { + data[@"request"] = requestSide; + } + NSDictionary *responseSide = [self sanitizeNetworkSide:breadcrumb.data[@"response"]]; + if (responseSide != nil) { + data[@"response"] = responseSide; + } return [SentrySessionReplayHybridSDK createNetworkBreadcrumbWithTimestamp:[NSDate @@ -194,6 +202,18 @@ + (NSString *_Nullable)getTouchPathMessageFrom:(NSArray *_Nullable)path data:data]; } +// Copy a JS-emitted request/response side dict, dropping the JS-internal `_meta` +// warnings field so it does not leak into the native rrweb span event. +- (NSDictionary *_Nullable)sanitizeNetworkSide:(id _Nullable)raw +{ + if (![raw isKindOfClass:[NSDictionary class]]) { + return nil; + } + NSMutableDictionary *out = [(NSDictionary *)raw mutableCopy]; + [out removeObjectForKey:@"_meta"]; + return out.count > 0 ? [out copy] : nil; +} + @end #endif diff --git a/packages/core/src/js/replay/mobilereplay.ts b/packages/core/src/js/replay/mobilereplay.ts index 51b23e2a12..2d465523b6 100644 --- a/packages/core/src/js/replay/mobilereplay.ts +++ b/packages/core/src/js/replay/mobilereplay.ts @@ -2,11 +2,13 @@ import type { Client, DynamicSamplingContext, ErrorEvent, Event, EventHint, Inte import { debug } from '@sentry/core'; +import type { ResolvedNetworkOptions } from './networkUtils'; + import { isHardCrash } from '../misc'; import { hasHooks } from '../utils/clientutils'; import { isExpoGo, notMobileOs } from '../utils/environment'; import { NATIVE } from '../wrapper'; -import { enrichXhrBreadcrumbsForMobileReplay } from './xhrUtils'; +import { makeEnrichXhrBreadcrumbsForMobileReplay } from './xhrUtils'; export const MOBILE_REPLAY_INTEGRATION_NAME = 'MobileReplay'; @@ -148,6 +150,66 @@ export interface MobileReplayOptions { * @returns `false` to skip capturing a replay for this error, `true` or `undefined` to proceed with sampling */ beforeErrorSampling?: (event: Event, hint: EventHint) => boolean; + + /** + * List of URLs (string match or RegExp) for which request and response details + * (headers and, when `networkCaptureBodies` is true, bodies) are captured and + * surfaced in the Replay network tab. + * + * String matches use substring matching; RegExp must match via `.test(url)`. + * Bodies are only captured for URLs that match `networkDetailAllowUrls` and + * do not match `networkDetailDenyUrls`. + * + * Authorization-like headers (`authorization`, `cookie`, `set-cookie`, + * `x-api-key`, `x-auth-token`, `proxy-authorization`) are always stripped. + * + * Currently only XHR requests are supported (this covers `axios` and similar + * libraries). Fetch body capture will be added in a follow-up. + * + * @default [] + */ + networkDetailAllowUrls?: (string | RegExp)[]; + + /** + * URLs (string match or RegExp) to exclude from network detail capture even + * if they match `networkDetailAllowUrls`. Use this to prevent capturing + * details for known-sensitive endpoints. + * + * @default [] + */ + networkDetailDenyUrls?: (string | RegExp)[]; + + /** + * If request and response bodies should be captured for URLs matched by + * `networkDetailAllowUrls`. When `false` (the default), only headers are + * captured for allow-listed URLs — opt in explicitly to record bodies, since + * they can contain sensitive payloads. + * + * Bodies are truncated at ~150 KB; truncated payloads include a + * `MAX_BODY_SIZE_EXCEEDED` warning. + * + * @default false + */ + networkCaptureBodies?: boolean; + + /** + * Additional request headers (case-insensitive names) to capture for matched + * URLs in addition to the defaults (`content-type`, `content-length`, `accept`). + * + * Note: only headers explicitly set on the `XMLHttpRequest` via + * `setRequestHeader` are observable; browser-managed headers are not. + * + * @default [] + */ + networkRequestHeaders?: string[]; + + /** + * Additional response headers (case-insensitive names) to capture for matched + * URLs in addition to the defaults (`content-type`, `content-length`, `accept`). + * + * @default [] + */ + networkResponseHeaders?: string[]; } const defaultOptions: MobileReplayOptions = { @@ -158,6 +220,11 @@ const defaultOptions: MobileReplayOptions = { enableViewRendererV2: true, enableFastViewRendering: false, screenshotStrategy: 'pixelCopy', + networkDetailAllowUrls: [], + networkDetailDenyUrls: [], + networkCaptureBodies: false, + networkRequestHeaders: [], + networkResponseHeaders: [], }; function mergeOptions(initOptions: Partial): MobileReplayOptions { @@ -318,7 +385,14 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau } }); - client.on('beforeAddBreadcrumb', enrichXhrBreadcrumbsForMobileReplay); + const networkOptions: ResolvedNetworkOptions = { + allowUrls: options.networkDetailAllowUrls ?? [], + denyUrls: options.networkDetailDenyUrls ?? [], + captureBodies: options.networkCaptureBodies ?? false, + requestHeaders: options.networkRequestHeaders ?? [], + responseHeaders: options.networkResponseHeaders ?? [], + }; + client.on('beforeAddBreadcrumb', makeEnrichXhrBreadcrumbsForMobileReplay(networkOptions)); // Wrap beforeSend to run processEvent after user's beforeSend const clientOptions = client.getOptions(); diff --git a/packages/core/src/js/replay/networkUtils.ts b/packages/core/src/js/replay/networkUtils.ts index 68493f25d0..469bb4b125 100644 --- a/packages/core/src/js/replay/networkUtils.ts +++ b/packages/core/src/js/replay/networkUtils.ts @@ -54,3 +54,180 @@ function _serializeFormData(formData: FormData): string { // @ts-expect-error passing FormData to URLSearchParams won't correctly serialize `File` entries, which is fine for this use-case. See https://github.com/microsoft/TypeScript/issues/30584 return new URLSearchParams(formData).toString(); } + +export const NETWORK_BODY_MAX_SIZE = 150_000; + +export const DEFAULT_NETWORK_HEADERS = ['content-type', 'content-length', 'accept']; + +const DENY_HEADERS = new Set([ + 'authorization', + 'cookie', + 'set-cookie', + 'x-api-key', + 'x-auth-token', + 'proxy-authorization', +]); + +export interface ResolvedNetworkOptions { + allowUrls: (string | RegExp)[]; + denyUrls: (string | RegExp)[]; + captureBodies: boolean; + requestHeaders: string[]; + responseHeaders: string[]; +} + +/** + * Check if a URL matches any pattern in the list. Strings use substring match + * (empty strings are ignored to avoid matching everything by accident); + * RegExp uses `.test()` with `lastIndex` reset so global-flag patterns are + * stateless across calls. + */ +function _urlMatches(url: string, patterns: (string | RegExp)[]): boolean { + for (const pattern of patterns) { + if (typeof pattern === 'string') { + if (pattern.length > 0 && url.indexOf(pattern) !== -1) { + return true; + } + } else if (pattern instanceof RegExp) { + // Reset lastIndex to make /g and /y patterns idempotent across calls. + pattern.lastIndex = 0; + if (pattern.test(url)) { + return true; + } + } + } + return false; +} + +/** + * Whether to capture full network details (headers, bodies) for a given URL, + * based on the allow/deny URL lists. + */ +export function shouldCaptureNetworkDetails(url: string | undefined, options: ResolvedNetworkOptions): boolean { + if (!url || options.allowUrls.length === 0) { + return false; + } + if (!_urlMatches(url, options.allowUrls)) { + return false; + } + if (options.denyUrls.length > 0 && _urlMatches(url, options.denyUrls)) { + return false; + } + return true; +} + +export interface NetworkBody { + body?: string; + /** Warnings about the captured body, e.g. truncation or unserializable type. */ + _meta?: { warnings: string[] }; +} + +/** + * Serialize a request/response body to a string, truncated to NETWORK_BODY_MAX_SIZE. + * Returns undefined if the body is empty/missing; returns a meta warning if unserializable + * or truncated. + */ +export function getBodyString(body: unknown): NetworkBody | undefined { + if (body == null) { + return undefined; + } + + try { + let bodyStr: string | undefined; + + if (typeof body === 'string') { + bodyStr = body; + } else if (typeof body === 'number' || typeof body === 'boolean') { + // JSON primitives (e.g. `xhr.response` with responseType='json' returning `42` or `true`) + bodyStr = String(body); + } else if (body instanceof URLSearchParams) { + bodyStr = body.toString(); + } else if (body instanceof FormData) { + bodyStr = _serializeFormData(body); + } else if (body instanceof Blob || body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { + // Binary payloads (Blob, ArrayBuffer, typed arrays like Uint8Array) + // can't be safely inlined as text; record the type but skip the body. + return { _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }; + } else if (typeof body === 'object') { + // Last-ditch attempt: try to JSON-stringify plain objects (e.g. xhr.response with responseType='json') + try { + bodyStr = JSON.stringify(body); + } catch { + return { _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }; + } + } + + if (bodyStr === undefined) { + return { _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }; + } + + if (bodyStr.length > NETWORK_BODY_MAX_SIZE) { + return { + body: bodyStr.slice(0, NETWORK_BODY_MAX_SIZE), + _meta: { warnings: ['MAX_BODY_SIZE_EXCEEDED'] }, + }; + } + + return { body: bodyStr }; + } catch { + return { _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }; + } +} + +/** + * Filter a headers map down to the set explicitly captured (defaults + user-supplied) + * and strip authorization-like headers. Header name comparison is case-insensitive; + * the returned keys are lowercased. + */ +export function filterHeaders( + headers: Record | undefined, + extraAllowed: string[], +): Record | undefined { + if (!headers) { + return undefined; + } + const allowed = new Set([...DEFAULT_NETWORK_HEADERS, ...extraAllowed.map(h => h.toLowerCase())]); + const out: Record = {}; + let count = 0; + for (const rawName of Object.keys(headers)) { + const name = rawName.toLowerCase(); + if (DENY_HEADERS.has(name)) { + continue; + } + if (!allowed.has(name)) { + continue; + } + const value = headers[rawName]; + if (value == null) { + continue; + } + out[name] = value; + count += 1; + } + return count > 0 ? out : undefined; +} + +/** Parse the raw string returned by XMLHttpRequest.getAllResponseHeaders() into a record. */ +export function parseAllResponseHeaders(raw: string | null | undefined): Record { + if (!raw) { + return {}; + } + const result: Record = {}; + // Headers are CRLF-delimited; some implementations use LF only. + const lines = raw.split(/\r?\n/); + for (const line of lines) { + if (!line) { + continue; + } + const idx = line.indexOf(':'); + if (idx === -1) { + continue; + } + const name = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + if (name) { + result[name] = value; + } + } + return result; +} diff --git a/packages/core/src/js/replay/xhrUtils.ts b/packages/core/src/js/replay/xhrUtils.ts index 40daca2372..593197e6e7 100644 --- a/packages/core/src/js/replay/xhrUtils.ts +++ b/packages/core/src/js/replay/xhrUtils.ts @@ -2,14 +2,55 @@ import type { Breadcrumb, BreadcrumbHint, SentryWrappedXMLHttpRequest, XhrBreadc import { dropUndefinedKeys } from '@sentry/core'; -import type { RequestBody } from './networkUtils'; +import type { NetworkBody, RequestBody, ResolvedNetworkOptions } from './networkUtils'; -import { getBodySize, parseContentLengthHeader } from './networkUtils'; +import { + filterHeaders, + getBodySize, + getBodyString, + parseAllResponseHeaders, + parseContentLengthHeader, + shouldCaptureNetworkDetails, +} from './networkUtils'; + +interface NetworkBreadcrumbSide { + body?: string; + headers?: Record; + _meta?: { warnings: string[] }; +} + +const DEFAULT_NETWORK_OPTIONS: ResolvedNetworkOptions = { + allowUrls: [], + denyUrls: [], + captureBodies: false, + requestHeaders: [], + responseHeaders: [], +}; + +/** + * Build a `beforeAddBreadcrumb` handler that enriches XHR breadcrumbs with + * network details (sizes always; headers/bodies for URLs matching the allow + * list and not the deny list) for the Mobile Replay network tab. + */ +export function makeEnrichXhrBreadcrumbsForMobileReplay( + networkOptions: ResolvedNetworkOptions, +): (breadcrumb: Breadcrumb, hint: BreadcrumbHint | undefined) => void { + return (breadcrumb, hint) => enrichXhrBreadcrumb(breadcrumb, hint, networkOptions); +} /** * Enrich an XHR breadcrumb with additional data for Mobile Replay network tab. + * Preserves the legacy behaviour: sizes only, no headers/bodies. */ export function enrichXhrBreadcrumbsForMobileReplay(breadcrumb: Breadcrumb, hint: BreadcrumbHint | undefined): void { + enrichXhrBreadcrumb(breadcrumb, hint, DEFAULT_NETWORK_OPTIONS); +} + +function enrichXhrBreadcrumb( + breadcrumb: Breadcrumb, + hint: BreadcrumbHint | undefined, + networkOptions: ResolvedNetworkOptions, +): void { if (breadcrumb.category !== 'xhr' || !hint) { return; } @@ -27,15 +68,107 @@ export function enrichXhrBreadcrumbsForMobileReplay(breadcrumb: Breadcrumb, hint ? parseContentLengthHeader(xhr.getResponseHeader('content-length')) : _getBodySize(xhr.response, xhr.responseType); + let request: NetworkBreadcrumbSide | undefined; + let response: NetworkBreadcrumbSide | undefined; + + const url = typeof breadcrumb.data?.url === 'string' ? breadcrumb.data.url : undefined; + + if (shouldCaptureNetworkDetails(url, networkOptions)) { + request = _buildRequestDetails(input, xhr, networkOptions); + response = _buildResponseDetails(xhr, networkOptions); + } + breadcrumb.data = dropUndefinedKeys({ start_timestamp: startTimestamp, end_timestamp: endTimestamp, request_body_size: reqSize, response_body_size: resSize, ...breadcrumb.data, + request, + response, }); } +function _buildRequestDetails( + input: RequestBody | undefined, + xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest, + networkOptions: ResolvedNetworkOptions, +): NetworkBreadcrumbSide | undefined { + const sentryXhr = xhr.__sentry_xhr_v3__; + const headers = filterHeaders(sentryXhr?.request_headers, networkOptions.requestHeaders); + + let body: NetworkBody | undefined; + if (networkOptions.captureBodies) { + body = getBodyString(input); + } + + return _toBreadcrumbSide(headers, body); +} + +function _buildResponseDetails( + xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest, + networkOptions: ResolvedNetworkOptions, +): NetworkBreadcrumbSide | undefined { + let rawHeaders: string | null = null; + try { + rawHeaders = xhr.getAllResponseHeaders(); + } catch { + // ignore — some environments may throw before the request is complete + } + const headers = filterHeaders(parseAllResponseHeaders(rawHeaders), networkOptions.responseHeaders); + + let body: NetworkBody | undefined; + if (networkOptions.captureBodies) { + body = _getResponseBodyString(xhr); + } + + return _toBreadcrumbSide(headers, body); +} + +function _toBreadcrumbSide( + headers: Record | undefined, + body: NetworkBody | undefined, +): NetworkBreadcrumbSide | undefined { + const side: NetworkBreadcrumbSide = {}; + if (headers) { + side.headers = headers; + } + if (body?.body !== undefined) { + side.body = body.body; + } + if (body?._meta) { + side._meta = body._meta; + // Native converters strip `_meta` before forwarding the side to the rrweb + // span (the native replay SDKs don't know about it). Materialize a + // placeholder `body` whenever we have a warning but no concrete body so + // the signal (e.g. UNPARSEABLE_BODY_TYPE) still surfaces in Session + // Replay instead of being silently dropped natively. + if (side.body === undefined) { + side.body = `[${body._meta.warnings.join(', ')}]`; + } + } + return Object.keys(side).length > 0 ? side : undefined; +} + +function _getResponseBodyString(xhr: XMLHttpRequest): NetworkBody | undefined { + try { + if (xhr.responseType === '' || xhr.responseType === 'text') { + // responseText only exists for text/empty responseType + return getBodyString(xhr.responseText); + } + if (xhr.responseType === 'json') { + return getBodyString(xhr.response); + } + if (xhr.response == null) { + return undefined; + } + // For 'blob' / 'arraybuffer' / 'document' we don't attempt to inline binary data. + return { _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }; + } catch { + return { _meta: { warnings: ['UNPARSEABLE_BODY_TYPE'] } }; + } +} + type XhrHint = XhrBreadcrumbHint & { xhr: XMLHttpRequest & SentryWrappedXMLHttpRequest; input?: RequestBody; diff --git a/packages/core/test/replay/xhrUtils.test.ts b/packages/core/test/replay/xhrUtils.test.ts index 477e8bc661..ff577d99d4 100644 --- a/packages/core/test/replay/xhrUtils.test.ts +++ b/packages/core/test/replay/xhrUtils.test.ts @@ -1,6 +1,10 @@ import type { Breadcrumb } from '@sentry/core'; -import { enrichXhrBreadcrumbsForMobileReplay } from '../../src/js/replay/xhrUtils'; +import { NETWORK_BODY_MAX_SIZE } from '../../src/js/replay/networkUtils'; +import { + enrichXhrBreadcrumbsForMobileReplay, + makeEnrichXhrBreadcrumbsForMobileReplay, +} from '../../src/js/replay/xhrUtils'; describe('xhrUtils', () => { describe('enrichXhrBreadcrumbsForMobileReplay', () => { @@ -67,23 +71,336 @@ describe('xhrUtils', () => { }), ); }); + + it('does not capture bodies or headers when allow list is empty', () => { + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrichXhrBreadcrumbsForMobileReplay(breadcrumb, getValidXhrHint()); + expect(breadcrumb.data?.request).toBeUndefined(); + expect(breadcrumb.data?.response).toBeUndefined(); + }); + }); + + describe('makeEnrichXhrBreadcrumbsForMobileReplay', () => { + it('captures request and response when url matches allow list', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['api.example.com'], + denyUrls: [], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], + }); + + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(breadcrumb, getValidXhrHint()); + + expect(breadcrumb.data?.request).toEqual({ + body: 'test-input', + headers: { 'content-type': 'application/json' }, + }); + expect(breadcrumb.data?.response).toEqual( + expect.objectContaining({ + body: '{"ok":true}', + headers: expect.objectContaining({ 'content-type': 'application/json' }), + }), + ); + }); + + it('skips capture when URL does not match allow list', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['api.other.com'], + denyUrls: [], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], + }); + + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(breadcrumb, getValidXhrHint()); + + expect(breadcrumb.data?.request).toBeUndefined(); + expect(breadcrumb.data?.response).toBeUndefined(); + }); + + it('skips capture when URL matches deny list', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['api.example.com'], + denyUrls: ['/users'], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], + }); + + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(breadcrumb, getValidXhrHint()); + + expect(breadcrumb.data?.request).toBeUndefined(); + expect(breadcrumb.data?.response).toBeUndefined(); + }); + + it('ignores empty-string allow patterns instead of matching every URL', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['', 'api.other.com'], + denyUrls: [], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], + }); + + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(breadcrumb, getValidXhrHint()); + + expect(breadcrumb.data?.request).toBeUndefined(); + expect(breadcrumb.data?.response).toBeUndefined(); + }); + + it('handles global-flag RegExp patterns idempotently across calls', () => { + const globalPattern = /api\.example\.com/g; + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: [globalPattern], + denyUrls: [], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], + }); + + for (let i = 0; i < 3; i += 1) { + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(breadcrumb, getValidXhrHint()); + expect(breadcrumb.data?.request).toBeDefined(); + } + }); + + it('honours RegExp patterns in allow/deny lists', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: [/^https:\/\/api\.example\.com\//], + denyUrls: [/\/secret/], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], + }); + + const allowed: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(allowed, getValidXhrHint()); + expect(allowed.data?.request).toBeDefined(); + + const denied: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/secret/key' } }; + enrich(denied, getValidXhrHint()); + expect(denied.data?.request).toBeUndefined(); + }); + + it('omits bodies when networkCaptureBodies is false but keeps headers', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['api.example.com'], + denyUrls: [], + captureBodies: false, + requestHeaders: [], + responseHeaders: [], + }); + + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(breadcrumb, getValidXhrHint()); + + expect(breadcrumb.data?.request).toEqual({ headers: { 'content-type': 'application/json' } }); + expect(breadcrumb.data?.response).toEqual( + expect.objectContaining({ + headers: expect.objectContaining({ 'content-type': 'application/json' }), + }), + ); + expect((breadcrumb.data?.response as { body?: string }).body).toBeUndefined(); + }); + + it('marks binary request bodies (Blob, ArrayBuffer, typed arrays) as unparseable with a placeholder body so the side survives the native _meta strip', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['api.example.com'], + denyUrls: [], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], + }); + + const blobBreadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(blobBreadcrumb, { ...getValidXhrHint(), input: new Blob(['binary']) }); + const blobRequest = blobBreadcrumb.data?.request as { body?: string; _meta?: { warnings: string[] } }; + expect(blobRequest.body).toBe('[UNPARSEABLE_BODY_TYPE]'); + expect(blobRequest._meta?.warnings).toEqual(['UNPARSEABLE_BODY_TYPE']); + + const bufferBreadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(bufferBreadcrumb, { ...getValidXhrHint(), input: new ArrayBuffer(16) }); + const bufferRequest = bufferBreadcrumb.data?.request as { body?: string; _meta?: { warnings: string[] } }; + expect(bufferRequest.body).toBe('[UNPARSEABLE_BODY_TYPE]'); + expect(bufferRequest._meta?.warnings).toEqual(['UNPARSEABLE_BODY_TYPE']); + + const typedArrayBreadcrumb: Breadcrumb = { + category: 'xhr', + data: { url: 'https://api.example.com/users' }, + }; + enrich(typedArrayBreadcrumb, { ...getValidXhrHint(), input: new Uint8Array([1, 2, 3]) }); + const typedArrayRequest = typedArrayBreadcrumb.data?.request as { + body?: string; + _meta?: { warnings: string[] }; + }; + expect(typedArrayRequest.body).toBe('[UNPARSEABLE_BODY_TYPE]'); + expect(typedArrayRequest._meta?.warnings).toEqual(['UNPARSEABLE_BODY_TYPE']); + }); + + it('stringifies primitive JSON responses (number, boolean) instead of marking them unparseable', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['api.example.com'], + denyUrls: [], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], + }); + + const numberBreadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + const numberHint = getValidXhrHint(); + numberHint.xhr.response = 42 as unknown as { ok: boolean }; + enrich(numberBreadcrumb, numberHint); + const numberResponse = numberBreadcrumb.data?.response as { body?: string; _meta?: unknown }; + expect(numberResponse.body).toBe('42'); + expect(numberResponse._meta).toBeUndefined(); + + const boolBreadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + const boolHint = getValidXhrHint(); + boolHint.xhr.response = true as unknown as { ok: boolean }; + enrich(boolBreadcrumb, boolHint); + const boolResponse = boolBreadcrumb.data?.response as { body?: string; _meta?: unknown }; + expect(boolResponse.body).toBe('true'); + expect(boolResponse._meta).toBeUndefined(); + }); + + it('truncates bodies that exceed the size cap', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['api.example.com'], + denyUrls: [], + captureBodies: true, + requestHeaders: [], + responseHeaders: [], + }); + + const bigBody = 'a'.repeat(NETWORK_BODY_MAX_SIZE + 100); + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(breadcrumb, { ...getValidXhrHint(), input: bigBody }); + + const request = breadcrumb.data?.request as { body?: string; _meta?: { warnings: string[] } }; + expect(request.body?.length).toBe(NETWORK_BODY_MAX_SIZE); + expect(request._meta?.warnings).toEqual(['MAX_BODY_SIZE_EXCEEDED']); + }); + + it('strips deny-listed sensitive headers regardless of configuration', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['api.example.com'], + denyUrls: [], + captureBodies: false, + requestHeaders: ['authorization', 'cookie'], + responseHeaders: ['set-cookie'], + }); + + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(breadcrumb, getXhrHintWithSensitiveHeaders()); + + const requestHeaders = (breadcrumb.data?.request as { headers?: Record }).headers ?? {}; + const responseHeaders = (breadcrumb.data?.response as { headers?: Record }).headers ?? {}; + expect(requestHeaders.authorization).toBeUndefined(); + expect(requestHeaders.cookie).toBeUndefined(); + expect(responseHeaders['set-cookie']).toBeUndefined(); + expect(requestHeaders['content-type']).toBe('application/json'); + }); + + it('captures additional opt-in headers but not arbitrary headers', () => { + const enrich = makeEnrichXhrBreadcrumbsForMobileReplay({ + allowUrls: ['api.example.com'], + denyUrls: [], + captureBodies: false, + requestHeaders: ['x-trace-id'], + responseHeaders: ['x-request-id'], + }); + + const breadcrumb: Breadcrumb = { category: 'xhr', data: { url: 'https://api.example.com/users' } }; + enrich(breadcrumb, getXhrHintWithCustomHeaders()); + + const requestHeaders = (breadcrumb.data?.request as { headers?: Record }).headers ?? {}; + const responseHeaders = (breadcrumb.data?.response as { headers?: Record }).headers ?? {}; + expect(requestHeaders['x-trace-id']).toBe('trace-123'); + expect(requestHeaders['x-unrelated']).toBeUndefined(); + expect(responseHeaders['x-request-id']).toBe('req-456'); + expect(responseHeaders['x-rate-limit']).toBeUndefined(); + }); }); }); function getValidXhrHint() { + const responseHeadersRaw = 'content-type: application/json\r\ncontent-length: 13'; return { startTimestamp: 1, endTimestamp: 2, input: 'test-input', // 10 bytes xhr: { + __sentry_xhr_v3__: { + method: 'GET', + url: 'https://api.example.com/users', + request_headers: { 'content-type': 'application/json' }, + }, getResponseHeader: (key: string) => { if (key === 'content-length') { return '13'; } throw new Error('Invalid key'); }, - response: 'test-response', // 13 bytes - responseType: 'json', + getAllResponseHeaders: () => responseHeadersRaw, + response: { ok: true }, // serialized 'test-response' is 13 bytes + responseText: '{"ok":true}', + responseType: 'json' as const, + }, + }; +} + +function getXhrHintWithSensitiveHeaders() { + const responseHeadersRaw = 'content-type: application/json\r\nset-cookie: session=abc\r\ncontent-length: 13'; + return { + startTimestamp: 1, + endTimestamp: 2, + input: 'test-input', + xhr: { + __sentry_xhr_v3__: { + method: 'GET', + url: 'https://api.example.com/users', + request_headers: { + 'content-type': 'application/json', + Authorization: 'Bearer secret', + Cookie: 'session=abc', + }, + }, + getResponseHeader: (_key: string) => null, + getAllResponseHeaders: () => responseHeadersRaw, + response: 'test-response', + responseText: 'test-response', + responseType: 'text' as const, + }, + }; +} + +function getXhrHintWithCustomHeaders() { + const responseHeadersRaw = 'content-type: application/json\r\nx-request-id: req-456\r\nx-rate-limit: 100'; + return { + startTimestamp: 1, + endTimestamp: 2, + input: 'test-input', + xhr: { + __sentry_xhr_v3__: { + method: 'GET', + url: 'https://api.example.com/users', + request_headers: { + 'content-type': 'application/json', + 'X-Trace-Id': 'trace-123', + 'X-Unrelated': 'whatever', + }, + }, + getResponseHeader: (_key: string) => null, + getAllResponseHeaders: () => responseHeadersRaw, + response: 'test-response', + responseText: 'test-response', + responseType: 'text' as const, }, }; }