diff --git a/CHANGELOG.md b/CHANGELOG.md index f5dbb6ecae..3cd9c30126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,25 @@ ## Unreleased +### Features + +- Connect React Native traces with embedded WebViews via `sentryWebViewProps` ([#6794](https://github.com/getsentry/sentry-react-native/pull/6794)) + + ```jsx + import { sentryWebViewProps } from '@sentry/react-native'; + + + ``` + ### Fixes - Find `ios/.xcode.env` in the Expo plugin "Upload Debug Symbols to Sentry" phase when the iOS project uses Swift Package Manager instead of CocoaPods ([#6911](https://github.com/getsentry/sentry-react-native/pull/6911)) ## 8.31.0 -### Features - - Add the `applicationKey` and `moduleMetadata` options to `withSentryConfig` and `getSentryExpoConfig`, and export `thirdPartyErrorFilterIntegration` ([#6863](https://github.com/getsentry/sentry-react-native/pull/6863)) - Set the same key in `applicationKey` and in the `filterKeys` option of `thirdPartyErrorFilterIntegration` to filter errors from third-party code, for example browser extensions on Expo web. diff --git a/packages/core/etc/sentry-react-native.api.md b/packages/core/etc/sentry-react-native.api.md index f666d3121e..87ace5d5e5 100644 --- a/packages/core/etc/sentry-react-native.api.md +++ b/packages/core/etc/sentry-react-native.api.md @@ -1081,6 +1081,17 @@ export type SentryReplayQuality = 'low' | 'medium' | 'high'; export function sentryTraceGesture( label: string, gesture: GestureT): GestureT; +// @public (undocumented) +export interface SentryWebViewOptions { + allowedHosts: string[]; +} + +// @public +export function sentryWebViewProps(options: SentryWebViewOptions): { + injectedJavaScriptBeforeContentLoaded: string; + onMessage: () => void; +}; + export { setAttribute } export { setAttributes } diff --git a/packages/core/src/js/index.ts b/packages/core/src/js/index.ts index 6581a17422..5621c60528 100644 --- a/packages/core/src/js/index.ts +++ b/packages/core/src/js/index.ts @@ -149,9 +149,17 @@ export { wrapExpoRouterErrorBoundary, wrapExpoImage, wrapExpoAsset, + sentryWebViewProps, } from './tracing'; -export type { TimeToDisplayProps, ExpoRouter, ExpoRouterErrorBoundaryProps, ExpoImage, ExpoAsset } from './tracing'; +export type { + TimeToDisplayProps, + ExpoRouter, + ExpoRouterErrorBoundaryProps, + ExpoImage, + ExpoAsset, + SentryWebViewOptions, +} from './tracing'; export type { ReactNativeTracingOptions } from './tracing/reactnativetracing'; export type { ReactNativeNavigationOptions } from './tracing/reactnativenavigation'; diff --git a/packages/core/src/js/tracing/index.ts b/packages/core/src/js/tracing/index.ts index a5780bb648..e8411609fb 100644 --- a/packages/core/src/js/tracing/index.ts +++ b/packages/core/src/js/tracing/index.ts @@ -31,6 +31,9 @@ export { ReactNativeProfiler } from './reactnativeprofiler'; export { sentryTraceGesture } from './gesturetracing'; +export { sentryWebViewProps } from './webview'; +export type { SentryWebViewOptions } from './webview'; + export * from './ops'; export * from './timetodisplay'; diff --git a/packages/core/src/js/tracing/webview.ts b/packages/core/src/js/tracing/webview.ts new file mode 100644 index 0000000000..12b3fb38a4 --- /dev/null +++ b/packages/core/src/js/tracing/webview.ts @@ -0,0 +1,213 @@ +import type { SerializedTraceData } from '@sentry/core'; + +import { debug, getTraceData, objectToBaggageHeader, parseBaggageHeader } from '@sentry/core'; + +const WEBVIEW_LABEL = 'Sentry WebView'; + +/** + * `baggage` key carrying the root span's name. It is free-form text and can + * contain user data (for example a parameterized URL), so it is removed from the + * trace context before it leaves the app and reaches the WebView page. + */ +const SENTRY_TRANSACTION_BAGGAGE_KEY = 'sentry-transaction'; + +export interface SentryWebViewOptions { + /** + * Hosts whose pages receive the injected Sentry trace context. + * + * Provide bare hostnames. A page is matched when its hostname equals an entry or + * is a subdomain of it (e.g. `'example.com'` matches both `example.com` and + * `app.example.com`). Entries are normalized before comparison — surrounding + * whitespace, a scheme, a path, a port, and a trailing dot are stripped, and + * matching is case-insensitive. Only listed hosts are instrumented, so the trace + * context — which can carry release and environment data in `baggage` — never + * reaches third-party pages loaded in the WebView. + */ + allowedHosts: string[]; +} + +/** + * Props to spread onto a `react-native-webview` `` to connect its traces + * with the React Native app's trace. + * + * The returned `injectedJavaScriptBeforeContentLoaded` writes the current trace as + * `sentry-trace` / `baggage` `` tags into the WebView document during its + * initial load, so a `@sentry/browser` running in the page continues the same trace + * on pageload (its pageload transaction and errors share the trace). The in-WebView + * page must run `@sentry/browser` for the connection to take effect. + * + * Timing note: on iOS the tags are written before any page script runs. On Android + * `react-native-webview` injects the script asynchronously as the page starts, so + * the tags can land slightly after the page's first synchronous `` scripts. + * `@sentry/browser` loaded the usual way — from a `'], + ['escaped backslash then quote', '\\";window.__pwned=1;//'], + ['statement + comment', '"}]);window.__pwned=1;//'], + ['newline injection', '\nwindow.__pwned=1;\n'], + ['carriage return injection', '\rwindow.__pwned=1;\r'], + ['U+2028 line separator', '\u2028window.__pwned=1;'], + ['U+2029 paragraph separator', '\u2029window.__pwned=1;'], + ['null byte', 'abc\u0000;window.__pwned=1;'], + ]; + + describe('via the sentry-trace value', () => { + it.each(BREAKOUT_PAYLOADS)('neutralizes %s', (_name, payload) => { + // Arrange + mockGetTraceData.mockReturnValue({ 'sentry-trace': payload }); + const { injectedJavaScriptBeforeContentLoaded } = sentryWebViewProps({ allowedHosts: ['example.com'] }); + + // Act + const { metas, pwned } = runFuzz(injectedJavaScriptBeforeContentLoaded, 'example.com'); + + // Assert — the payload is preserved verbatim as inert content, never run. + expect(pwned).toBe(false); + expect(metas).toEqual([{ name: 'sentry-trace', content: payload }]); + }); + }); + + describe('via a baggage entry value', () => { + it.each(BREAKOUT_PAYLOADS)('neutralizes %s', (_name, payload) => { + // Arrange — carry the payload as a URL-encoded baggage entry value. + mockGetTraceData.mockReturnValue({ + 'sentry-trace': SENTRY_TRACE, + baggage: `sentry-release=${encodeURIComponent(payload)}`, + }); + const { injectedJavaScriptBeforeContentLoaded } = sentryWebViewProps({ allowedHosts: ['example.com'] }); + + // Act + const { pwned } = runFuzz(injectedJavaScriptBeforeContentLoaded, 'example.com'); + + // Assert + expect(pwned).toBe(false); + }); + }); + + describe('via an allowedHosts entry', () => { + it.each(BREAKOUT_PAYLOADS)('neutralizes %s', (_name, payload) => { + // Arrange — a hostile host alongside the real one the page is served from. + mockGetTraceData.mockReturnValue({ 'sentry-trace': SENTRY_TRACE }); + const { injectedJavaScriptBeforeContentLoaded } = sentryWebViewProps({ + allowedHosts: [payload, 'example.com'], + }); + + // Act + const { pwned } = runFuzz(injectedJavaScriptBeforeContentLoaded, 'example.com'); + + // Assert + expect(pwned).toBe(false); + }); + }); + + it('preserves arbitrary random sentry-trace values as inert content', () => { + // Arrange — property-style fuzzing over a dangerous character set. + const CHARS = '"\'`\\${}();/<>\n\r\t\u2028\u2029abc0= ,'; + // Seeded PRNG (Park–Miller MINSTD) keeps the run deterministic, so a failing + // case is reproducible instead of depending on `Math.random()`. Uses only + // arithmetic (stays within safe-integer range) to satisfy the no-bitwise rule. + let seed = 123456789; + const rand = (): number => { + seed = (seed * 48271) % 2147483647; + return seed / 2147483647; + }; + for (let i = 0; i < 200; i++) { + const length = 1 + Math.floor(rand() * 40); + let payload = ''; + for (let c = 0; c < length; c++) { + payload += CHARS[Math.floor(rand() * CHARS.length)]; + } + mockGetTraceData.mockReturnValue({ 'sentry-trace': payload }); + const { injectedJavaScriptBeforeContentLoaded } = sentryWebViewProps({ allowedHosts: ['example.com'] }); + + // Act + const { metas, pwned } = runFuzz(injectedJavaScriptBeforeContentLoaded, 'example.com'); + + // Assert — never executes, always round-trips as content. + expect(pwned).toBe(false); + expect(metas).toEqual([{ name: 'sentry-trace', content: payload }]); + mockGetTraceData.mockReset(); + } + }); + }); +}); diff --git a/samples/react-native/e2e/tests/webViewTraceConnection/webViewTraceConnection.test.ts b/samples/react-native/e2e/tests/webViewTraceConnection/webViewTraceConnection.test.ts new file mode 100644 index 0000000000..b740ee864c --- /dev/null +++ b/samples/react-native/e2e/tests/webViewTraceConnection/webViewTraceConnection.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from '@jest/globals'; + +import { maestro } from '../../utils/maestro'; + +describe('WebView trace connection', () => { + it('injects the React Native trace into the WebView, which reads it back', async () => { + // The check runs on-device in the Maestro flow: it opens the WebView demo + // screen and asserts the "WebView trace connected" status becomes visible, + // which only happens when the page reads back the injected `sentry-trace` + // meta tag and it matches the trace active in React Native. A failed + // injection makes the flow exit non-zero, rejecting this promise. + await expect( + maestro('tests/webViewTraceConnection/webViewTraceConnection.test.yml'), + ).resolves.toBeUndefined(); + }, 240000); +}); diff --git a/samples/react-native/e2e/tests/webViewTraceConnection/webViewTraceConnection.test.yml b/samples/react-native/e2e/tests/webViewTraceConnection/webViewTraceConnection.test.yml new file mode 100644 index 0000000000..90dc2babb9 --- /dev/null +++ b/samples/react-native/e2e/tests/webViewTraceConnection/webViewTraceConnection.test.yml @@ -0,0 +1,29 @@ +appId: io.sentry.reactnative.sample +--- +- launchApp: + clearState: true + stopApp: true + arguments: + isE2ETest: true + +- tapOn: + id: 'playground-tab-icon' + +- extendedWaitUntil: + visible: + id: 'webview-example-button' + timeout: 30000 + +- tapOn: + id: 'webview-example-button' + +# The WebView loads the demo page, which reads the injected `sentry-trace` meta +# tag and posts it back to React Native. React Native compares it against the +# trace that was active when the screen mounted and flips the status to connected. +# If the trace context is not injected or not readable in the page, the status +# stays "not connected" and this assertion times out (failing the flow). +- extendedWaitUntil: + visible: + id: 'webview-trace-status' + text: 'WebView trace connected.*' + timeout: 30000 diff --git a/samples/react-native/src/Screens/WebviewScreen.tsx b/samples/react-native/src/Screens/WebviewScreen.tsx index a891b87bd6..cec07ee088 100644 --- a/samples/react-native/src/Screens/WebviewScreen.tsx +++ b/samples/react-native/src/Screens/WebviewScreen.tsx @@ -1,19 +1,163 @@ +import { getTraceData, SDK_VERSION } from '@sentry/core'; +import * as Sentry from '@sentry/react-native'; import * as React from 'react'; -import { StyleSheet } from 'react-native'; +import { StyleSheet, Text, View } from 'react-native'; import { WebView } from 'react-native-webview'; +import { getCurrentDsn, SENTRY_INTERNAL_DSN } from '../dsn'; + +// The trace context is only injected into pages served from an allow-listed host. +// `source.html` is loaded under `baseUrl`, so the page's `location.hostname` +// matches this entry and the injection runs. +const ALLOWED_HOST = 'sentry-webview-demo.local'; +const BASE_URL = `https://${ALLOWED_HOST}/`; + +// The in-WebView page runs `@sentry/browser`, so its pageload transaction and +// errors continue the trace injected by `sentryWebViewProps` — this is what a real +// app does. `@sentry/browser` is loaded from the CDN pinned to `SDK_VERSION` (the +// `@sentry/core` version this SDK bundles), so the page and the app always share the +// same JS SDK version. The page also reads the injected `sentry-trace` meta tag and +// posts it back to React Native, so the connection stays observable on-device even +// offline, when the CDN script can't load. +function buildDemoPage(dsn: string): string { + return ` + + + + + + + +

WebView page

+

Injected sentry-trace:

+
(reading…)
+ + +`; +} + const WebviewScreen = () => { + // Capture the trace and the injection props once at mount. `getTraceData()` is + // read on every render, and the active `sentry-trace` changes (its `span_id`) + // once the navigation span ends — so re-reading it later would compare the + // WebView's readback against a different value and flip the status to "not + // connected" even though the page received the injected trace correctly. + const [rnTrace] = React.useState(() => getTraceData()['sentry-trace']); + const webViewProps = React.useMemo(() => Sentry.sentryWebViewProps({ allowedHosts: [ALLOWED_HOST] }), []); + const [received, setReceived] = React.useState(null); + + const matches = received != null && received === rnTrace; + + // The WebView page's `@sentry/browser` reports to the same DSN as the app, so the + // connected pageload shows up in the same project. Built once per mount. + const demoPage = React.useMemo(() => buildDemoPage(getCurrentDsn() ?? SENTRY_INTERNAL_DSN), []); + return ( - + + + React Native trace + + {rnTrace ?? '(no active trace)'} + + WebView received + + {received ?? '(waiting…)'} + + + {matches ? 'WebView trace connected ✓' : 'WebView trace not connected'} + + + { + try { + const data = JSON.parse(event.nativeEvent.data); + setReceived(data.sentryTrace ?? '(none)'); + } catch { + // ignore non-JSON messages from the page + } + }} + style={styles.webview} + /> + ); }; export default WebviewScreen; const styles = StyleSheet.create({ + container: { + flex: 1, + }, + panel: { + padding: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#ccc', + }, + label: { + fontSize: 12, + fontWeight: '600', + color: '#888', + marginTop: 8, + }, + value: { + fontSize: 13, + fontFamily: 'Courier', + color: '#1e1b2e', + }, + status: { + marginTop: 12, + fontSize: 14, + fontWeight: '600', + }, + match: { + color: '#2e7d32', + }, + pending: { + color: '#b26a00', + }, webview: { flex: 1, }, diff --git a/samples/react-native/src/tabs/PlaygroundTab.tsx b/samples/react-native/src/tabs/PlaygroundTab.tsx index 2d3b3ce713..6bd49544a6 100644 --- a/samples/react-native/src/tabs/PlaygroundTab.tsx +++ b/samples/react-native/src/tabs/PlaygroundTab.tsx @@ -5,9 +5,11 @@ import { ImageBackground, Keyboard, KeyboardAvoidingView, + Platform, Pressable, SafeAreaView, ScrollView, + StatusBar, StyleSheet, Text, TextInput, @@ -41,6 +43,11 @@ const styles = StyleSheet.create({ padding: 5, flex: 1, }, + // RN's SafeAreaView is a no-op on Android, so clear the status bar explicitly + // to keep the first button tappable (and out from under the status bar). + scrollContent: { + paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight ?? 0 : 0, + }, separator: { marginVertical: 8, }, @@ -68,10 +75,11 @@ export default function getPlaygroundTab() { - +