From baf1646dce2ae5ea9be9e9037ac5966092a413c9 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 9 Nov 2026 12:14:06 +0100 Subject: [PATCH] feat(android): Use tracePropagationTargets as the default WebView hosts Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../api/sentry-android-core.api | 1 + .../android/core/SentryWebViewTracing.java | 61 +++++++++++++++++-- .../core/WebViewTracePropagationScript.java | 47 +++++++++++++- .../android/core/SentryWebViewTracingTest.kt | 39 ++++++++++++ .../core/WebViewTracePropagationScriptTest.kt | 42 +++++++++++++ 6 files changed, 185 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e69fd3fd7a9..a0f25925221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Connect Android traces with web content in a `WebView` using `SentryWebViewTracing.addTracePropagation(webView, "example.com")` ([#6395](https://github.com/getsentry/sentry-java/pull/6395)) - Requires the `androidx.webkit:webkit` dependency. Only list hosts whose content you control. - Call it on the main thread before each page load you want connected. + - Call `addTracePropagation(webView)` without hosts to use the plain host entries of `tracePropagationTargets`. Regexes, including the default `.*`, are not used. - See the [WebView docs](https://docs.sentry.io/platforms/android/configuration/webview/) for details. - Monitor every Spring `@Scheduled` method with Sentry Crons by setting `sentry.monitor-scheduled-tasks=true`, without adding `@SentryCheckIn` to each method. Sentry creates each monitor from the method's `@Scheduled` schedule. Exclude methods with `sentry.exclude-scheduled-tasks` ([#6275](https://github.com/getsentry/sentry-java/pull/6275), [#6276](https://github.com/getsentry/sentry-java/pull/6276), [#6277](https://github.com/getsentry/sentry-java/pull/6277)) - Quartz `SentryJobListener` can send a monitor config derived from the trigger with check-ins, so Sentry creates or updates the monitor from code. Set the `sentry-upsert-monitor-config` job or trigger data key to `"true"` to send the schedule ([#6216](https://github.com/getsentry/sentry-java/pull/6216)) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 85c33e2772d..e620e56cf60 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -629,6 +629,7 @@ public abstract interface class io/sentry/android/core/SentryUserFeedbackForm$Op } public final class io/sentry/android/core/SentryWebViewTracing { + public static fun addTracePropagation (Landroid/webkit/WebView;)Z public static fun addTracePropagation (Landroid/webkit/WebView;Ljava/util/List;)Z public static fun addTracePropagation (Landroid/webkit/WebView;[Ljava/lang/String;)Z } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java index ad15c82ab3a..e9eb01bd5fc 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java @@ -49,6 +49,28 @@ public final class SentryWebViewTracing { private SentryWebViewTracing() {} + /** + * Same as {@link #addTracePropagation(WebView, List)}, using the plain host entries of {@link + * io.sentry.SentryOptions#getTracePropagationTargets()} as the allowed hosts. Regex entries, + * entries with a path and hosts without a dot are skipped, so the default {@code .*} connects no + * page: set the targets to hosts whose content you control. An entry with a scheme only matches + * that exact host. + * + * @param webView the WebView to connect + * @return true if the trace propagation script was installed + */ + @MainThread + public static boolean addTracePropagation(final @NotNull WebView webView) { + final @NotNull IScopes scopes = Sentry.getCurrentScopes(); + return addTracePropagation( + webView, + WebViewTracePropagationScript.hostsFromTracePropagationTargets( + scopes.getOptions().getTracePropagationTargets()), + scopes, + new LoadClass(), + true); + } + /** * Same as {@link #addTracePropagation(WebView, List)}. * @@ -96,6 +118,15 @@ static boolean addTracePropagation( final @NotNull List allowedHosts, final @NotNull IScopes scopes, final @NotNull LoadClass loadClass) { + return addTracePropagation(webView, allowedHosts, scopes, loadClass, false); + } + + private static boolean addTracePropagation( + final @NotNull WebView webView, + final @NotNull List allowedHosts, + final @NotNull IScopes scopes, + final @NotNull LoadClass loadClass, + final boolean fromTracePropagationTargets) { // Registered on use rather than on success, like the other SDKs, so adoption counts apps that // wired the feature up even when this call cannot install. addIntegrationToSdkVersion(INTEGRATION_NAME); @@ -109,7 +140,12 @@ static boolean addTracePropagation( "WebView trace propagation requires the androidx.webkit dependency, skipping."); return false; } - return install(webView, allowedHosts, scopes, new AndroidxWebViewScriptInstaller()); + return install( + webView, + allowedHosts, + scopes, + new AndroidxWebViewScriptInstaller(), + fromTracePropagationTargets); } static boolean install( @@ -117,6 +153,15 @@ static boolean install( final @NotNull List allowedHosts, final @NotNull IScopes scopes, final @NotNull WebViewScriptInstaller installer) { + return install(webView, allowedHosts, scopes, installer, false); + } + + private static boolean install( + final @NotNull WebView webView, + final @NotNull List allowedHosts, + final @NotNull IScopes scopes, + final @NotNull WebViewScriptInstaller installer, + final boolean fromTracePropagationTargets) { final @NotNull ILogger logger = scopes.getOptions().getLogger(); // WebView methods throw when called off the WebView's thread; fail like any other misuse. @@ -129,7 +174,8 @@ static boolean install( } try { - return replaceScript(webView, allowedHosts, scopes, installer, logger); + return replaceScript( + webView, allowedHosts, scopes, installer, logger, fromTracePropagationTargets); } catch (LinkageError e) { // androidx.webkit is compileOnly, so the version in the app's apk may be older than the one // we compiled against and lack the APIs used here. @@ -150,7 +196,8 @@ private static boolean replaceScript( final @NotNull List allowedHosts, final @NotNull IScopes scopes, final @NotNull WebViewScriptInstaller installer, - final @NotNull ILogger logger) { + final @NotNull ILogger logger, + final boolean fromTracePropagationTargets) { // Remove Sentry's previous script first, so a failed re-add never leaves a stale trace behind. installer.removeInstalledScript(webView); @@ -158,7 +205,9 @@ private static boolean replaceScript( if (hosts.isEmpty()) { logger.log( SentryLevel.WARNING, - "No valid allowedHosts provided, skipping WebView trace propagation."); + fromTracePropagationTargets + ? "tracePropagationTargets has no plain host entry (regexes and entries with a path are not used), skipping WebView trace propagation." + : "No valid allowedHosts provided, skipping WebView trace propagation."); return false; } @@ -188,7 +237,9 @@ private static boolean replaceScript( try { if (!installer.installScript( - webView, script, WebViewTracePropagationScript.originRules(allowedHosts))) { + webView, + script, + WebViewTracePropagationScript.originRules(allowedHosts, fromTracePropagationTargets))) { logger.log( SentryLevel.WARNING, "The WebView does not support document-start scripts, skipping WebView trace propagation."); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/WebViewTracePropagationScript.java b/sentry-android-core/src/main/java/io/sentry/android/core/WebViewTracePropagationScript.java index 5a22ea69388..7a4816846d8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/WebViewTracePropagationScript.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/WebViewTracePropagationScript.java @@ -30,8 +30,36 @@ final class WebViewTracePropagationScript { private static final @NotNull Pattern PORT = Pattern.compile(":\\d+$"); private static final @NotNull Pattern IPV4 = Pattern.compile("^\\d{1,3}(\\.\\d{1,3}){3}$"); + /** A target that names a host: optional scheme, host, optional port, nothing else. */ + private static final @NotNull Pattern PLAIN_HOST_TARGET = + Pattern.compile( + "^([a-z][a-z0-9+.-]*://)?(\\*\\.)?[\\p{L}\\p{N}][\\p{L}\\p{N}.-]*(:\\d+)?/?$", + Pattern.CASE_INSENSITIVE); + private WebViewTracePropagationScript() {} + /** + * Returns the entries of {@code tracePropagationTargets} that name a plain host. Targets are + * matched as substrings or regexes against a full URL, which cannot be turned into the origin + * rules that gate the script, so regexes (including the default {@code .*}) and entries with a + * path are skipped rather than widened. So are hosts without a dot other than {@code localhost}: + * a loose substring like {@code com} would otherwise allow every site under that domain. + */ + static @NotNull List hostsFromTracePropagationTargets( + final @NotNull Collection targets) { + final @NotNull List hosts = new ArrayList<>(); + for (final @Nullable String target : targets) { + if (target == null || !PLAIN_HOST_TARGET.matcher(target.trim()).matches()) { + continue; + } + final @NotNull String host = normalizeHost(target); + if (host.contains(".") || "localhost".equals(host)) { + hosts.add(target); + } + } + return hosts; + } + /** * Normalizes each entry with {@link #normalizeHost(String)}, dropping blank entries and * duplicates. @@ -119,6 +147,17 @@ private static boolean isAscii(final @NotNull String value) { * port 443, so a non-default port given in an entry is kept and limits the rule to that port. */ static @NotNull Set originRules(final @NotNull Collection allowedHosts) { + return originRules(allowedHosts, false); + } + + /** + * Same as {@link #originRules(Collection)}. With {@code fromTracePropagationTargets}, an entry + * with a scheme and no subdomain wildcard only matches that exact host, as it does for HTTP + * propagation. The native origin rules are what enforce this: the script itself also accepts + * subdomains, but it never runs on them. + */ + static @NotNull Set originRules( + final @NotNull Collection allowedHosts, final boolean fromTracePropagationTargets) { final @NotNull Set rules = new LinkedHashSet<>(); for (final @Nullable String entry : allowedHosts) { if (entry == null) { @@ -131,13 +170,19 @@ private static boolean isAscii(final @NotNull String value) { final @Nullable String port = port(entry); final @NotNull String portSuffix = port == null || "443".equals(port) ? "" : ":" + port; rules.add("https://" + host + portSuffix); - if (!isIpLiteral(host)) { + if (!isIpLiteral(host) && !(fromTracePropagationTargets && isExactHostTarget(entry))) { rules.add("https://*." + host + portSuffix); } } return rules; } + private static boolean isExactHostTarget(final @NotNull String entry) { + final @NotNull String trimmed = entry.trim(); + final @NotNull String withoutScheme = SCHEME.matcher(trimmed).replaceFirst(""); + return !withoutScheme.equals(trimmed) && !withoutScheme.startsWith("*."); + } + /** Returns the port of an {@code allowedHosts} entry, or null if it has none. */ private static @Nullable String port(final @NotNull String entry) { String hostAndPort = SCHEME.matcher(entry.trim()).replaceFirst(""); diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt index fd532c2ff65..0983449ea89 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt @@ -20,6 +20,7 @@ import kotlin.test.assertFailsWith import org.junit.runner.RunWith import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.times @@ -367,6 +368,44 @@ class SentryWebViewTracingTest { } } + @Test + fun `without hosts uses the plain host entries of tracePropagationTargets`() { + Sentry.init { + it.dsn = "https://key@sentry.io/123" + it.setTracePropagationTargets( + listOf("example.com", "https://api.other.org/", ".*\\.other\\.com") + ) + } + val origins = argumentCaptor>() + mockStatic(WebViewCompat::class.java).use { webViewCompat -> + mockStatic(WebViewFeature::class.java).use { webViewFeature -> + webViewFeature + .`when` { + WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT) + } + .thenReturn(true) + webViewCompat + .`when` { + WebViewCompat.addDocumentStartJavaScript(eq(webView), any(), origins.capture()) + } + .thenReturn(mock()) + + assertThat(SentryWebViewTracing.addTracePropagation(webView)).isTrue() + } + } + + assertThat(origins.firstValue) + .containsExactly("https://example.com", "https://*.example.com", "https://api.other.org") + } + + @Test + fun `without hosts connects no page with the default tracePropagationTargets`() { + initSentry() + + assertThat(SentryWebViewTracing.addTracePropagation(webView)).isFalse() + assertThat(webView.getTag(R.id.sentry_webview_trace_propagation)).isNull() + } + @Test fun `androidx installer clears the handler even when its removal throws`() { val handler = mock() diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/WebViewTracePropagationScriptTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/WebViewTracePropagationScriptTest.kt index 210bdf80dad..2e4ae3f8a90 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/WebViewTracePropagationScriptTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/WebViewTracePropagationScriptTest.kt @@ -365,4 +365,46 @@ class WebViewTracePropagationScriptTest { "abc\u0000;window.__pwned=1;", ) } + + @Test + fun `only plain hosts are taken from tracePropagationTargets`() { + val plain = + listOf( + "example.com", + "*.example.org", + "https://api.example.net", + "https://example.io:8443/", + "münchen.de", + "localhost", + ) + val skipped = + listOf( + ".*", + "https://api\\.example\\.com/.*", + ".*\\.example\\.com", + "example.com/api", + "https://example.com/.*", + "^example\\.com$", + "com", + "com.", + "", + ) + + assertThat(WebViewTracePropagationScript.hostsFromTracePropagationTargets(plain + skipped)) + .containsExactlyElementsIn(plain) + } + + @Test + fun `a tracePropagationTargets entry with a scheme only matches that exact host`() { + fun rules(entry: String) = WebViewTracePropagationScript.originRules(listOf(entry), true) + + assertThat(rules("https://api.example.com/")).containsExactly("https://api.example.com") + assertThat(rules("api.example.com")) + .containsExactly("https://api.example.com", "https://*.api.example.com") + assertThat(rules("https://*.example.com")) + .containsExactly("https://example.com", "https://*.example.com") + // Explicit allowedHosts keep matching subdomains. + assertThat(WebViewTracePropagationScript.originRules(listOf("https://api.example.com/"))) + .containsExactly("https://api.example.com", "https://*.api.example.com") + } }