diff --git a/CHANGELOG.md b/CHANGELOG.md index 85bbfc57c8f..90d75064201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +### Features + +- Connect Android traces with web content in a `WebView` using `SentryWebViewTracing.addTracePropagation(webView, "example.com")` ([#6395](https://github.com/getsentry/sentry-java/pull/6395)) + - Before page scripts run, the current trace is written into pages from the allowed hosts as `sentry-trace` and `baggage` meta tags, so `@sentry/browser` running in the page continues the trace on pageload. + - Requires the `androidx.webkit:webkit` dependency in your app and an Android System WebView that supports document-start scripts; otherwise the call logs a warning and returns `false`. + - Only list hosts whose content you control. Call it on the main thread before loading a URL, and again to propagate a newer trace to a later page load. + ### Fixes - Keep UI profiling running when `startProfiler` is called before a requested stop takes effect at the end of the current chunk ([#6291](https://github.com/getsentry/sentry-java/pull/6291)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7cbe8e626c8..b763402ae5e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -124,6 +124,7 @@ androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room2" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room2" } androidx-room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" } androidx-room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" } +androidx-webkit = { module = "androidx.webkit:webkit", version = "1.14.0" } androidx-sqlite = { module = "androidx.sqlite:sqlite", version.ref = "sqlite" } androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqliteRc" } androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqliteRc" } @@ -295,6 +296,7 @@ okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version. # apollo-runtime 5.x pulls in okhttp 5.x; mockwebserver 4.x is incompatible with that classpath okhttp-mockwebserver5 = { module = "com.squareup.okhttp3:mockwebserver", version = "5.3.0" } okio = { module = "com.squareup.okio:okio", version = "1.13.0" } +rhino = { module = "org.mozilla:rhino", version = "1.8.0" } roboelectric = { module = "org.robolectric:robolectric", version = "4.15" } [bundles] diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index e8b95794ea0..47d41726d39 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -623,6 +623,11 @@ public abstract interface class io/sentry/android/core/SentryUserFeedbackForm$Op public abstract fun configure (Landroid/content/Context;Lio/sentry/SentryFeedbackOptions;)V } +public final class io/sentry/android/core/SentryWebViewTracing { + public static fun addTracePropagation (Landroid/webkit/WebView;Ljava/util/List;)Z + public static fun addTracePropagation (Landroid/webkit/WebView;[Ljava/lang/String;)Z +} + public class io/sentry/android/core/SpanFrameMetricsCollector : io/sentry/IPerformanceContinuousCollector, io/sentry/android/core/internal/util/SentryFrameMetricsCollector$FrameMetricsCollectorListener { protected final field lock Lio/sentry/util/AutoClosableReentrantLock; public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/android/core/internal/util/SentryFrameMetricsCollector;)V diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 4facf6f4c23..b4bd503a4d4 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -100,6 +100,8 @@ dependencies { implementation(libs.androidx.core) // photo picker for user feedback screenshot attachments compileOnly(libs.androidx.activity) + // document-start script injection for WebView trace propagation + compileOnly(libs.androidx.webkit) implementation(libs.epitaph) @@ -128,6 +130,8 @@ dependencies { testImplementation(projects.sentryAndroidNdk) testImplementation(libs.androidx.activity.compose) + testImplementation(libs.androidx.webkit) + testImplementation(libs.rhino) testImplementation(libs.androidx.compose.ui) testImplementation(libs.androidx.compose.foundation) testImplementation(libs.androidx.compose.foundation.layout) diff --git a/sentry-android-core/proguard-rules.pro b/sentry-android-core/proguard-rules.pro index eadc6917af7..34fc51f709a 100644 --- a/sentry-android-core/proguard-rules.pro +++ b/sentry-android-core/proguard-rules.pro @@ -30,6 +30,12 @@ -dontwarn androidx.activity.ComponentActivity -dontwarn androidx.activity.result.** +# androidx.webkit is a compileOnly dependency, used by WebView trace propagation +# its presence is checked at runtime before use +-dontwarn androidx.webkit.** +# keep the name of the class the presence check looks up, so it still finds it in minified apps +-keepnames class androidx.webkit.ScriptHandler + # To ensure that stack traces is unambiguous # https://developer.android.com/studio/build/shrink-code#decode-stack-trace -keepattributes LineNumberTable,SourceFile diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidxWebViewScriptInstaller.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidxWebViewScriptInstaller.java new file mode 100644 index 00000000000..0bdc2501814 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidxWebViewScriptInstaller.java @@ -0,0 +1,47 @@ +package io.sentry.android.core; + +import android.webkit.WebView; +import androidx.webkit.ScriptHandler; +import androidx.webkit.WebViewCompat; +import androidx.webkit.WebViewFeature; +import java.util.Set; +import org.jetbrains.annotations.NotNull; + +/** + * Installs Sentry's document-start script through androidx.webkit. All androidx.webkit references + * are isolated in this class, so it must only be loaded once {@link SentryWebViewTracing} has + * checked that androidx.webkit is available. That check deliberately lives outside of this class, + * so that it can run without linking any androidx.webkit type. + */ +final class AndroidxWebViewScriptInstaller implements WebViewScriptInstaller { + + @Override + public boolean isDocumentStartScriptSupported() { + return WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT); + } + + @Override + public void removeInstalledScript(final @NotNull WebView webView) { + final Object handler = webView.getTag(R.id.sentry_webview_trace_propagation); + // A handler only exists if the feature was supported when it was installed. + if (handler instanceof ScriptHandler + && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) { + ((ScriptHandler) handler).remove(); + } + webView.setTag(R.id.sentry_webview_trace_propagation, null); + } + + @Override + public boolean installScript( + final @NotNull WebView webView, + final @NotNull String script, + final @NotNull Set allowedOriginRules) { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) { + return false; + } + final @NotNull ScriptHandler handler = + WebViewCompat.addDocumentStartJavaScript(webView, script, allowedOriginRules); + webView.setTag(R.id.sentry_webview_trace_propagation, handler); + return true; + } +} 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 new file mode 100644 index 00000000000..a32b250b22e --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryWebViewTracing.java @@ -0,0 +1,246 @@ +package io.sentry.android.core; + +import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; + +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.webkit.WebView; +import androidx.annotation.MainThread; +import io.sentry.BaggageHeader; +import io.sentry.ILogger; +import io.sentry.IScopes; +import io.sentry.Sentry; +import io.sentry.SentryLevel; +import io.sentry.util.LoadClass; +import io.sentry.util.TracingUtils; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Connects the app's trace with the trace of web content shown in a {@link WebView}. + * + *

{@link #addTracePropagation(WebView, List)} writes the current trace into every document the + * WebView loads from an allowed host as {@code sentry-trace} and {@code baggage} meta tags, before + * any page script runs. A {@code @sentry/browser} running in the page with {@code + * browserTracingIntegration} then continues the trace on pageload. + * + *

Requires the {@code androidx.webkit:webkit} dependency and an Android System WebView that + * supports {@code WebViewFeature.DOCUMENT_START_SCRIPT}. + */ +public final class SentryWebViewTracing { + + static final @NotNull String SCRIPT_HANDLER_CLASS_NAME = "androidx.webkit.ScriptHandler"; + + private static final @NotNull String INTEGRATION_NAME = "WebViewTracePropagation"; + + /** + * Counts, per WebView, the calls that finished replacing Sentry's script on the WebView's thread. + * Weak keys, so it doesn't keep WebViews alive. + */ + private static final @NotNull Map replacements = + Collections.synchronizedMap(new WeakHashMap<>()); + + private SentryWebViewTracing() {} + + /** + * Same as {@link #addTracePropagation(WebView, List)}. + * + * @param webView the WebView to connect + * @param allowedHosts hosts whose pages receive the trace + * @return true if the trace propagation script was installed + */ + @MainThread + public static boolean addTracePropagation( + final @NotNull WebView webView, final @NotNull String... allowedHosts) { + return addTracePropagation(webView, Arrays.asList(allowedHosts)); + } + + /** + * Installs a script that writes the current trace into documents the WebView loads from an + * allowed host. Call it on the main thread, before loading a URL: it applies to documents that + * start loading after the call. + * + *

{@code allowedHosts} is a trust boundary: only list hosts whose content you control. A page + * matches when its hostname equals an entry or is a subdomain of it, over https. Entries are + * normalized, so a scheme, a path, and a trailing dot are ignored. A port other than 443 limits + * the entry to pages served on that port (for example {@code "example.com:8443"}). + * + *

The trace is a snapshot taken when this is called. To propagate a newer trace to a later + * page load, call this again: it replaces the script Sentry installed earlier and leaves other + * scripts alone. If the call fails, no Sentry script is left on the WebView. + * + *

The root span name ({@code sentry-transaction}) is removed from {@code baggage}, as it is + * free-form text that may carry user data. + * + * @param webView the WebView to connect + * @param allowedHosts hosts whose pages receive the trace + * @return true if the script was installed; false if androidx.webkit is missing, the WebView does + * not support document-start scripts, {@code allowedHosts} has no valid entry, or there is no + * trace (for example the SDK is not initialized), or it is called off the WebView's thread + */ + @MainThread + public static boolean addTracePropagation( + final @NotNull WebView webView, final @NotNull List allowedHosts) { + return addTracePropagation(webView, allowedHosts, Sentry.getCurrentScopes(), new LoadClass()); + } + + static boolean addTracePropagation( + final @NotNull WebView webView, + final @NotNull List allowedHosts, + final @NotNull IScopes scopes, + final @NotNull LoadClass loadClass) { + // 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); + + if (!loadClass.isClassAvailable(SCRIPT_HANDLER_CLASS_NAME, scopes.getOptions())) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "WebView trace propagation requires the androidx.webkit dependency, skipping."); + return false; + } + return install(webView, allowedHosts, scopes, new AndroidxWebViewScriptInstaller()); + } + + static boolean install( + final @NotNull WebView webView, + final @NotNull List allowedHosts, + final @NotNull IScopes scopes, + final @NotNull WebViewScriptInstaller installer) { + final @NotNull ILogger logger = scopes.getOptions().getLogger(); + + // WebView methods throw when called off the WebView's thread; fail like any other misuse. + if (!isOnWebViewThread(webView)) { + logger.log( + SentryLevel.WARNING, + "addTracePropagation must be called on the WebView's thread, skipping WebView trace propagation."); + removeOnWebViewThread(webView, installer, logger); + return false; + } + + try { + return replaceScript(webView, allowedHosts, scopes, installer, logger); + } 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. + logger.log( + SentryLevel.WARNING, + "androidx.webkit is too old for WebView trace propagation, skipping.", + e); + return false; + } finally { + // Counted once the replacement is done, so an off-thread call made while it runs doesn't + // later remove the script it installed. Only written on the WebView's thread. + replacements.put(webView, replacementsOf(webView) + 1); + } + } + + private static boolean replaceScript( + final @NotNull WebView webView, + final @NotNull List allowedHosts, + final @NotNull IScopes scopes, + final @NotNull WebViewScriptInstaller installer, + final @NotNull ILogger logger) { + // Remove Sentry's previous script first, so a failed re-add never leaves a stale trace behind. + installer.removeInstalledScript(webView); + + final @NotNull List hosts = WebViewTracePropagationScript.normalizeHosts(allowedHosts); + if (hosts.isEmpty()) { + logger.log( + SentryLevel.WARNING, + "No valid allowedHosts provided, skipping WebView trace propagation."); + return false; + } + + if (!installer.isDocumentStartScriptSupported()) { + logger.log( + SentryLevel.WARNING, + "The WebView does not support document-start scripts, skipping WebView trace propagation."); + return false; + } + + final @Nullable TracingUtils.TracingHeaders headers = + TracingUtils.trace(scopes, null, scopes.getSpan()); + if (headers == null) { + logger.log( + SentryLevel.DEBUG, + "No trace available (the SDK is disabled or not initialized), skipping WebView trace propagation."); + return false; + } + + final @Nullable BaggageHeader baggageHeader = headers.getBaggageHeader(); + final @Nullable String baggage = + WebViewTracePropagationScript.stripTransactionFromBaggage( + baggageHeader == null ? null : baggageHeader.getValue()); + final @NotNull String script = + WebViewTracePropagationScript.build( + hosts, headers.getSentryTraceHeader().getValue(), baggage); + + try { + if (!installer.installScript( + webView, script, WebViewTracePropagationScript.originRules(allowedHosts))) { + logger.log( + SentryLevel.WARNING, + "The WebView does not support document-start scripts, skipping WebView trace propagation."); + return false; + } + } catch (IllegalArgumentException e) { + logger.log( + SentryLevel.WARNING, "Invalid allowedHosts, skipping WebView trace propagation.", e); + return false; + } + + return true; + } + + /** + * A failed call must not leave a stale trace behind, but off the WebView's thread the script can + * only be removed later, on that thread. The removal is skipped if a call on the WebView's thread + * replaced this WebView's script in the meantime, so it never removes a newer script. + */ + private static void removeOnWebViewThread( + final @NotNull WebView webView, + final @NotNull WebViewScriptInstaller installer, + final @NotNull ILogger logger) { + final long replacementsSeen = replacementsOf(webView); + new Handler(webViewLooper(webView)) + .post( + () -> { + if (replacementsOf(webView) != replacementsSeen) { + return; + } + try { + installer.removeInstalledScript(webView); + } catch (LinkageError e) { + logger.log( + SentryLevel.WARNING, + "androidx.webkit is too old for WebView trace propagation, skipping.", + e); + } + }); + } + + private static long replacementsOf(final @NotNull WebView webView) { + final @Nullable Long count = replacements.get(webView); + return count == null ? 0 : count; + } + + private static boolean isOnWebViewThread(final @NotNull WebView webView) { + return Looper.myLooper() == webViewLooper(webView); + } + + private static @NotNull Looper webViewLooper(final @NotNull WebView webView) { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + ? webView.getWebViewLooper() + : Looper.getMainLooper(); + } +} diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/WebViewScriptInstaller.java b/sentry-android-core/src/main/java/io/sentry/android/core/WebViewScriptInstaller.java new file mode 100644 index 00000000000..36d92588fbc --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/WebViewScriptInstaller.java @@ -0,0 +1,26 @@ +package io.sentry.android.core; + +import android.webkit.WebView; +import java.util.Set; +import org.jetbrains.annotations.NotNull; + +/** + * Installs Sentry's document-start script on a WebView. Declares no androidx.webkit types, so it + * can be referenced before the androidx.webkit availability check. + */ +interface WebViewScriptInstaller { + + boolean isDocumentStartScriptSupported(); + + /** Removes the script Sentry installed earlier on this WebView, if any. */ + void removeInstalledScript(@NotNull WebView webView); + + /** + * Installs the script for documents whose origin matches one of the rules. + * + * @return false if document-start scripts are not supported, in which case nothing is installed + * @throws IllegalArgumentException if an origin rule is malformed + */ + boolean installScript( + @NotNull WebView webView, @NotNull String script, @NotNull Set allowedOriginRules); +} 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 new file mode 100644 index 00000000000..66763ce3233 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/WebViewTracePropagationScript.java @@ -0,0 +1,263 @@ +package io.sentry.android.core; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Builds the script that writes the native trace into a WebView document as {@code sentry-trace} + * and {@code baggage} meta tags, plus the origin rules that gate where it runs. Has no androidx + * dependency so it can be used before the androidx.webkit availability check. + */ +final class WebViewTracePropagationScript { + + /** + * {@code 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 before it reaches the WebView page. + */ + private static final @NotNull String SENTRY_TRANSACTION_BAGGAGE_KEY = "sentry-transaction"; + + private static final @NotNull Pattern SCHEME = + Pattern.compile("^[a-z][a-z0-9+.-]*://", Pattern.CASE_INSENSITIVE); + private static final @NotNull Pattern PORT = Pattern.compile(":\\d+$"); + private static final @NotNull Pattern IPV4 = Pattern.compile("^\\d{1,3}(\\.\\d{1,3}){3}$"); + + private WebViewTracePropagationScript() {} + + /** + * Normalizes each entry with {@link #normalizeHost(String)}, dropping blank entries and + * duplicates. + */ + static @NotNull List normalizeHosts(final @NotNull Collection allowedHosts) { + final @NotNull Set normalized = new LinkedHashSet<>(); + for (final @Nullable String host : allowedHosts) { + if (host == null) { + continue; + } + final @NotNull String normalizedHost = normalizeHost(host); + if (!normalizedHost.isEmpty()) { + normalized.add(normalizedHost); + } + } + return new ArrayList<>(normalized); + } + + /** + * Normalizes an {@code allowedHosts} entry to a bare lowercase hostname so it can be compared + * against the page's {@code location.hostname}. Tolerates surrounding whitespace, a scheme, a + * path, a port, a trailing dot, and a leading subdomain wildcard (e.g. {@code " + * https://*.App.Example.com:8080/x "} becomes {@code "app.example.com"}). IPv6 literals are + * bracketed to match {@code location.hostname} ({@code "::1"} becomes {@code "[::1]"}). + */ + static @NotNull String normalizeHost(final @NotNull String host) { + String hostAndPort = SCHEME.matcher(host.trim()).replaceFirst(""); + + final int pathStart = indexOfAny(hostAndPort, "/?#"); + if (pathStart != -1) { + hostAndPort = hostAndPort.substring(0, pathStart); + } + + // Strip the port without mistaking an IPv6 hextet for one. + final int closingBracket = hostAndPort.indexOf(']'); + if (closingBracket != -1) { + hostAndPort = hostAndPort.substring(0, closingBracket + 1); + } else if (hostAndPort.indexOf(':') != hostAndPort.lastIndexOf(':')) { + hostAndPort = "[" + hostAndPort + "]"; + } else { + hostAndPort = PORT.matcher(hostAndPort).replaceFirst(""); + } + + if (hostAndPort.endsWith(".")) { + hostAndPort = hostAndPort.substring(0, hostAndPort.length() - 1); + } + // Subdomains always match, so a subdomain wildcard is redundant. Kept, it would never match + // the page's hostname and would make the origin rules invalid. + while (hostAndPort.startsWith("*.")) { + hostAndPort = hostAndPort.substring(2); + } + return hostAndPort.toLowerCase(Locale.ROOT); + } + + /** + * Origin rules for {@code WebViewCompat.addDocumentStartJavaScript}: each host and its subdomains + * over https. IP literals cannot take a subdomain wildcard. A rule without a port only matches + * 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) { + final @NotNull Set rules = new LinkedHashSet<>(); + for (final @Nullable String entry : allowedHosts) { + if (entry == null) { + continue; + } + final @NotNull String host = normalizeHost(entry); + if (host.isEmpty()) { + continue; + } + final @Nullable String port = port(entry); + final @NotNull String portSuffix = port == null || "443".equals(port) ? "" : ":" + port; + rules.add("https://" + host + portSuffix); + if (!isIpLiteral(host)) { + rules.add("https://*." + host + portSuffix); + } + } + return rules; + } + + /** 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(""); + final int pathStart = indexOfAny(hostAndPort, "/?#"); + if (pathStart != -1) { + hostAndPort = hostAndPort.substring(0, pathStart); + } + + final int closingBracket = hostAndPort.indexOf(']'); + final @NotNull String afterHost; + if (closingBracket != -1) { + afterHost = hostAndPort.substring(closingBracket + 1); + } else if (hostAndPort.indexOf(':') != hostAndPort.lastIndexOf(':')) { + // An unbracketed IPv6 literal has no port. + return null; + } else { + final int colon = hostAndPort.indexOf(':'); + afterHost = colon == -1 ? "" : hostAndPort.substring(colon); + } + final @NotNull Matcher matcher = PORT.matcher(afterHost); + return matcher.matches() ? afterHost.substring(1) : null; + } + + /** + * Removes the {@code sentry-transaction} entry from a {@code baggage} header, keeping every other + * entry. Returns null when nothing is left to propagate. + */ + static @Nullable String stripTransactionFromBaggage(final @Nullable String baggage) { + if (baggage == null) { + return null; + } + final @NotNull StringBuilder result = new StringBuilder(); + for (final @NotNull String entry : baggage.split(",", -1)) { + final @NotNull String trimmed = entry.trim(); + if (trimmed.isEmpty()) { + continue; + } + final int separator = trimmed.indexOf('='); + final @NotNull String key = separator == -1 ? trimmed : trimmed.substring(0, separator); + if (SENTRY_TRANSACTION_BAGGAGE_KEY.equals(key.trim())) { + continue; + } + if (result.length() > 0) { + result.append(','); + } + result.append(trimmed); + } + return result.length() == 0 ? null : result.toString(); + } + + /** + * Builds the script run at document start. Values are embedded as escaped string literals so any + * character in them stays inert. The script only writes the tags in the main frame of a page + * whose hostname is (a subdomain of) an allowed host, and does nothing if a {@code sentry-trace} + * meta tag already exists. + */ + static @NotNull String build( + final @NotNull List normalizedHosts, + final @NotNull String sentryTrace, + final @Nullable String baggage) { + final @NotNull StringBuilder allowed = new StringBuilder("["); + for (int i = 0; i < normalizedHosts.size(); i++) { + if (i > 0) { + allowed.append(','); + } + allowed.append(toJsStringLiteral(normalizedHosts.get(i))); + } + allowed.append(']'); + + return "(function(){try{\n" + + "if(window.top!==window)return;\n" + + "var allowed=" + + allowed + + ";\n" + + "var host=(window.location&&window.location.hostname||'').toLowerCase();\n" + + "if(!host)return;\n" + + "if(!allowed.some(function(h){return host===h||host.slice(-(h.length+1))==='.'+h;}))return;\n" + + "if(document.querySelector('meta[name=\"sentry-trace\"]'))return;\n" + + "var head=document.head||document.documentElement;\n" + + "if(!head)return;\n" + + "var set=function(n,c){if(!c)return;var m=document.createElement('meta');" + + "m.setAttribute('name',n);m.setAttribute('content',c);head.appendChild(m);};\n" + + "set('sentry-trace'," + + toJsStringLiteral(sentryTrace) + + ");\n" + + "set('baggage'," + + (baggage == null ? "null" : toJsStringLiteral(baggage)) + + ");\n" + + "}catch(e){}})();"; + } + + /** + * Escapes a value as a double-quoted JavaScript string literal, like {@code JSON.stringify}. + * Additionally escapes U+2028/U+2029 (line terminators in pre-ES2019 engines) and {@code <} so + * the literal stays inert in any embedding context. + */ + static @NotNull String toJsStringLiteral(final @NotNull String value) { + final @NotNull StringBuilder sb = new StringBuilder(value.length() + 2); + sb.append('"'); + for (int i = 0; i < value.length(); i++) { + final char c = value.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + case '<': + case '
': + case '
': + appendUnicodeEscape(sb, c); + break; + default: + if (c < 0x20) { + appendUnicodeEscape(sb, c); + } else { + sb.append(c); + } + } + } + sb.append('"'); + return sb.toString(); + } + + private static void appendUnicodeEscape(final @NotNull StringBuilder sb, final char c) { + sb.append(String.format(Locale.ROOT, "\\u%04x", (int) c)); + } + + private static boolean isIpLiteral(final @NotNull String host) { + return host.startsWith("[") || IPV4.matcher(host).matches(); + } + + private static int indexOfAny(final @NotNull String value, final @NotNull String chars) { + for (int i = 0; i < value.length(); i++) { + if (chars.indexOf(value.charAt(i)) != -1) { + return i; + } + } + return -1; + } +} diff --git a/sentry-android-core/src/main/res/values/ids.xml b/sentry-android-core/src/main/res/values/ids.xml new file mode 100644 index 00000000000..5b6125d18e5 --- /dev/null +++ b/sentry-android-core/src/main/res/values/ids.xml @@ -0,0 +1,5 @@ + + + + + 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 new file mode 100644 index 00000000000..54b84d21232 --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryWebViewTracingTest.kt @@ -0,0 +1,359 @@ +package io.sentry.android.core + +import android.os.Looper +import android.webkit.WebView +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.webkit.ScriptHandler +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import com.google.common.truth.Truth.assertThat +import io.sentry.IScopes +import io.sentry.Sentry +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.TransactionOptions +import io.sentry.util.LoadClass +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.Shadows.shadowOf + +@RunWith(AndroidJUnit4::class) +class SentryWebViewTracingTest { + + private class FakeInstaller( + var supported: Boolean = true, + var installFailure: IllegalArgumentException? = null, + var linkageError: LinkageError? = null, + var onInstall: (() -> Unit)? = null, + ) : WebViewScriptInstaller { + var removeCount = 0 + val installed = mutableListOf>>() + + override fun isDocumentStartScriptSupported(): Boolean = supported + + override fun removeInstalledScript(webView: WebView) { + linkageError?.let { throw it } + removeCount++ + } + + override fun installScript( + webView: WebView, + script: String, + allowedOriginRules: Set, + ): Boolean { + installFailure?.let { throw it } + onInstall?.invoke() + installed.add(script to allowedOriginRules) + return true + } + } + + private lateinit var webView: WebView + private val installer = FakeInstaller() + + @BeforeTest + fun setUp() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + webView = WebView(ApplicationProvider.getApplicationContext()) + } + + @AfterTest + fun tearDown() { + Sentry.close() + } + + private fun initSentry(): IScopes { + Sentry.init { + it.dsn = "https://key@sentry.io/123" + it.release = "app@1.0" + it.environment = "prod" + it.tracesSampleRate = 1.0 + } + return Sentry.getCurrentScopes() + } + + private fun add( + allowedHosts: List = listOf("example.com"), + scopes: IScopes = Sentry.getCurrentScopes(), + ): Boolean = SentryWebViewTracing.install(webView, allowedHosts, scopes, installer) + + @Test + fun `installs the current trace for the allowed hosts`() { + val scopes = initSentry() + + assertThat(add()).isTrue() + + val (script, rules) = installer.installed.single() + assertThat(script).contains(scopes.traceparent!!.value) + assertThat(script).contains("sentry-release=app%401.0") + assertThat(rules).containsExactly("https://example.com", "https://*.example.com") + } + + @Test + fun `does not propagate the root span name`() { + initSentry() + val transaction = + Sentry.startTransaction( + "Checkout user@example.com", + "ui.load", + TransactionOptions().apply { isBindToScope = true }, + ) + + assertThat(add()).isTrue() + + val (script, _) = installer.installed.single() + assertThat(script).contains(transaction.toSentryTrace().value) + assertThat(script).contains("sentry-sampled=true") + assertThat(script).doesNotContain("sentry-transaction") + transaction.finish() + } + + @Test + fun `removes the previous script before installing again`() { + initSentry() + + assertThat(add()).isTrue() + assertThat(add()).isTrue() + + assertThat(installer.removeCount).isEqualTo(2) + assertThat(installer.installed).hasSize(2) + } + + @Test + fun `returns false and leaves no script when allowedHosts has no valid entry`() { + initSentry() + + assertThat(add(listOf(" ", ""))).isFalse() + + assertThat(installer.removeCount).isEqualTo(1) + assertThat(installer.installed).isEmpty() + } + + @Test + fun `returns false and leaves no script when document-start scripts are unsupported`() { + initSentry() + installer.supported = false + + assertThat(add()).isFalse() + + assertThat(installer.removeCount).isEqualTo(1) + assertThat(installer.installed).isEmpty() + } + + @Test + fun `returns false and leaves no script when the SDK is not initialized`() { + assertThat(add()).isFalse() + + assertThat(installer.removeCount).isEqualTo(1) + assertThat(installer.installed).isEmpty() + } + + @Test + fun `a re-add after Sentry close removes the previous script`() { + initSentry() + assertThat(add()).isTrue() + Sentry.close() + + assertThat(add()).isFalse() + + assertThat(installer.removeCount).isEqualTo(2) + assertThat(installer.installed).hasSize(1) + } + + @Test + fun `treats a subdomain wildcard entry like the bare host`() { + initSentry() + + assertThat(add(listOf("*.example.com"))).isTrue() + + val (_, rules) = installer.installed.single() + assertThat(rules).containsExactly("https://example.com", "https://*.example.com") + } + + @Test + fun `off the WebView's thread returns false and removes the previous script on that thread`() { + initSentry() + assertThat(add()).isTrue() + var result: Boolean? = null + + val thread = Thread { result = add() } + thread.start() + thread.join() + + assertThat(result).isFalse() + assertThat(installer.removeCount).isEqualTo(1) + shadowOf(Looper.getMainLooper()).idle() + assertThat(installer.removeCount).isEqualTo(2) + assertThat(installer.installed).hasSize(1) + } + + @Test + fun `the removal posted by an off-thread call does not remove a newer script`() { + initSentry() + val thread = Thread { add() } + thread.start() + thread.join() + + assertThat(add()).isTrue() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(installer.removeCount).isEqualTo(1) + assertThat(installer.installed).hasSize(1) + } + + @Test + fun `an off-thread call made while a script is installed does not remove it later`() { + initSentry() + installer.onInstall = { + // Runs on the WebView's thread while it installs the script. + val thread = Thread { add() } + thread.start() + thread.join() + } + + assertThat(add()).isTrue() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(installer.removeCount).isEqualTo(1) + assertThat(installer.installed).hasSize(1) + } + + @Test + fun `a call on another WebView does not cancel the removal posted for this one`() { + initSentry() + assertThat(add()).isTrue() + val otherWebView = WebView(ApplicationProvider.getApplicationContext()) + val thread = Thread { add() } + thread.start() + thread.join() + + assertThat( + SentryWebViewTracing.install( + otherWebView, + listOf("example.com"), + Sentry.getCurrentScopes(), + installer, + ) + ) + .isTrue() + shadowOf(Looper.getMainLooper()).idle() + + // One removal per on-thread call, plus the one posted for the first WebView. + assertThat(installer.removeCount).isEqualTo(3) + } + + @Test + fun `returns false when androidx webkit is too old`() { + initSentry() + installer.linkageError = NoSuchMethodError("addDocumentStartJavaScript") + + assertThat(add()).isFalse() + + assertThat(installer.installed).isEmpty() + } + + @Test + fun `passes a non-default port through to the origin rules`() { + initSentry() + + assertThat(add(listOf("example.com:8443"))).isTrue() + + val (_, rules) = installer.installed.single() + assertThat(rules).containsExactly("https://example.com:8443", "https://*.example.com:8443") + } + + @Test + fun `returns false when the origin rules are rejected`() { + initSentry() + installer.installFailure = IllegalArgumentException("bad rule") + + assertThat(add(listOf("*"))).isFalse() + } + + @Test + fun `returns false when androidx webkit is not available and still records adoption`() { + val scopes = initSentry() + val loadClass = mock() + whenever( + loadClass.isClassAvailable( + eq(SentryWebViewTracing.SCRIPT_HANDLER_CLASS_NAME), + any(), + ) + ) + .thenReturn(false) + + assertThat( + SentryWebViewTracing.addTracePropagation( + webView, + listOf("example.com"), + scopes, + loadClass, + ) + ) + .isFalse() + // The adoption marker is registered on use, even when nothing can be installed. + assertThat(SentryIntegrationPackageStorage.getInstance().integrations) + .contains("WebViewTracePropagation") + } + + @Test + fun `androidx installer stores the script handler and removes it on the next call`() { + val handler = mock() + 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(), any()) + } + .thenReturn(handler) + val androidxInstaller = AndroidxWebViewScriptInstaller() + + assertThat(androidxInstaller.isDocumentStartScriptSupported).isTrue() + androidxInstaller.removeInstalledScript(webView) + assertThat(androidxInstaller.installScript(webView, "script", setOf("https://example.com"))) + .isTrue() + assertThat(webView.getTag(R.id.sentry_webview_trace_propagation)).isSameInstanceAs(handler) + + androidxInstaller.removeInstalledScript(webView) + + verify(handler, times(1)).remove() + assertThat(webView.getTag(R.id.sentry_webview_trace_propagation)).isNull() + } + } + } + + @Test + fun `androidx installer installs nothing when document-start scripts are unsupported`() { + mockStatic(WebViewCompat::class.java).use { webViewCompat -> + mockStatic(WebViewFeature::class.java).use { webViewFeature -> + webViewFeature + .`when` { + WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT) + } + .thenReturn(false) + + val installed = + AndroidxWebViewScriptInstaller() + .installScript(webView, "script", setOf("https://example.com")) + + assertThat(installed).isFalse() + webViewCompat.verifyNoInteractions() + assertThat(webView.getTag(R.id.sentry_webview_trace_propagation)).isNull() + } + } + } +} 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 new file mode 100644 index 00000000000..707c343aeae --- /dev/null +++ b/sentry-android-core/src/test/java/io/sentry/android/core/WebViewTracePropagationScriptTest.kt @@ -0,0 +1,345 @@ +package io.sentry.android.core + +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test +import org.mozilla.javascript.Context +import org.mozilla.javascript.ScriptableObject + +class WebViewTracePropagationScriptTest { + + private data class Meta(val name: String, val content: String) + + private data class Result(val metas: List, val pwned: Boolean) + + /** + * Runs the injection script against a minimal fake DOM, like the React Native SDK tests do, and + * returns the meta tags present afterwards. A breakout payload would set `window.__pwned`. + */ + private fun runInjection( + script: String, + hostname: String, + existing: List = emptyList(), + mainFrame: Boolean = true, + ): Result { + val cx = Context.enter() + try { + cx.languageVersion = Context.VERSION_ES6 + val scope = cx.initStandardObjects() + ScriptableObject.putProperty(scope, "HOST", hostname) + ScriptableObject.putProperty(scope, "MAIN_FRAME", mainFrame) + cx.evaluateString( + scope, + """ + var metas = []; + var head = { appendChild: function(el) { metas.push({ name: el.name, content: el.content }); } }; + var document = { + head: head, + documentElement: head, + createElement: function() { + return { name: '', content: '', setAttribute: function(k, v) { this[k] = v; } }; + }, + querySelector: function(selector) { + var match = /meta\[name="([^"]+)"\]/.exec(selector); + if (!match) return null; + for (var i = 0; i < metas.length; i++) { + if (metas[i].name === match[1]) return metas[i]; + } + return null; + } + }; + var window = { location: { hostname: HOST } }; + window.top = MAIN_FRAME ? window : {}; + """ + .trimIndent(), + "harness", + 1, + null, + ) + for (meta in existing) { + cx.evaluateString( + scope, + "metas.push({ name: ${WebViewTracePropagationScript.toJsStringLiteral(meta.name)}, " + + "content: ${WebViewTracePropagationScript.toJsStringLiteral(meta.content)} });", + "existing", + 1, + null, + ) + } + + cx.evaluateString(scope, script, "injection", 1, null) + + val count = Context.toNumber(cx.evaluateString(scope, "metas.length", "count", 1, null)) + val metas = + (0 until count.toInt()).map { i -> + Meta( + Context.toString(cx.evaluateString(scope, "metas[$i].name", "name", 1, null)), + Context.toString(cx.evaluateString(scope, "metas[$i].content", "content", 1, null)), + ) + } + val pwned = + Context.toBoolean( + cx.evaluateString( + scope, + "window.__pwned === 1 || typeof __pwned !== 'undefined'", + "pwned", + 1, + null, + ) + ) + return Result(metas, pwned) + } finally { + Context.exit() + } + } + + private fun script( + allowedHosts: List = listOf("example.com"), + sentryTrace: String = SENTRY_TRACE, + baggage: String? = BAGGAGE, + ): String = + WebViewTracePropagationScript.build( + WebViewTracePropagationScript.normalizeHosts(allowedHosts), + sentryTrace, + baggage, + ) + + @Test + fun `injects sentry-trace and baggage meta tags on an allowed host`() { + val result = runInjection(script(), "example.com") + + assertThat(result.metas) + .containsExactly(Meta("sentry-trace", SENTRY_TRACE), Meta("baggage", BAGGAGE)) + .inOrder() + } + + @Test + fun `injects on a subdomain of an allowed host`() { + assertThat(runInjection(script(), "app.example.com").metas).hasSize(2) + } + + @Test + fun `matches allowed hosts case-insensitively`() { + assertThat(runInjection(script(listOf("Example.COM")), "app.example.com").metas).hasSize(2) + } + + @Test + fun `does not inject on a host that is not allowed`() { + assertThat(runInjection(script(), "evil.com").metas).isEmpty() + } + + @Test + fun `does not inject on a host that merely ends with an allowed host`() { + assertThat(runInjection(script(), "notexample.com").metas).isEmpty() + } + + @Test + fun `does not inject into iframes`() { + assertThat(runInjection(script(), "example.com", mainFrame = false).metas).isEmpty() + } + + @Test + fun `does not inject again when the page already has a sentry-trace meta tag`() { + val existing = listOf(Meta("sentry-trace", "already-there")) + + val result = runInjection(script(), "example.com", existing) + + assertThat(result.metas).containsExactly(Meta("sentry-trace", "already-there")) + } + + @Test + fun `injects only sentry-trace when there is no baggage`() { + val result = runInjection(script(baggage = null), "example.com") + + assertThat(result.metas).containsExactly(Meta("sentry-trace", SENTRY_TRACE)) + } + + @Test + fun `normalizes allowedHosts entries`() { + for (entry in + listOf( + " example.com ", + "https://example.com", + "example.com:8080", + "example.com/some/path", + "example.com.", + "https://Example.com:443/x?y#z", + )) { + assertThat(WebViewTracePropagationScript.normalizeHost(entry)).isEqualTo("example.com") + assertThat(runInjection(script(listOf(entry)), "example.com").metas).hasSize(2) + } + } + + @Test + fun `normalizes a subdomain wildcard entry to the bare host`() { + for (entry in listOf("*.example.com", "https://*.Example.com/x", "*.*.example.com")) { + assertThat(WebViewTracePropagationScript.normalizeHost(entry)).isEqualTo("example.com") + } + assertThat(runInjection(script(listOf("*.example.com")), "app.example.com").metas).hasSize(2) + assertThat(runInjection(script(listOf("*.example.com")), "example.com").metas).hasSize(2) + } + + @Test + fun `normalizes IPv6 allowedHosts entries to the bracketed hostname`() { + for ((entry, hostname) in + listOf( + "::1" to "[::1]", + "[::1]" to "[::1]", + "[::1]:8080" to "[::1]", + "2001:db8::1" to "[2001:db8::1]", + "[2001:DB8::1]" to "[2001:db8::1]", + )) { + assertThat(WebViewTracePropagationScript.normalizeHost(entry)).isEqualTo(hostname) + assertThat(runInjection(script(listOf(entry)), hostname).metas).hasSize(2) + } + } + + @Test + fun `normalizeHosts drops blank entries and duplicates`() { + val hosts = + WebViewTracePropagationScript.normalizeHosts( + listOf(" ", "", "example.com", "https://EXAMPLE.com/", "other.com") + ) + + assertThat(hosts).containsExactly("example.com", "other.com").inOrder() + } + + @Test + fun `originRules allow each host and its subdomains over https`() { + val rules = WebViewTracePropagationScript.originRules(listOf("example.com", "other.com")) + + assertThat(rules) + .containsExactly( + "https://example.com", + "https://*.example.com", + "https://other.com", + "https://*.other.com", + ) + } + + @Test + fun `originRules do not add a subdomain wildcard to IP literals`() { + val rules = WebViewTracePropagationScript.originRules(listOf("[::1]", "10.0.2.2")) + + assertThat(rules).containsExactly("https://[::1]", "https://10.0.2.2") + } + + @Test + fun `originRules keep a non-default port`() { + val rules = + WebViewTracePropagationScript.originRules( + listOf("example.com:8443", "https://other.com:443/x", "[::1]:8080", "10.0.2.2:3000") + ) + + assertThat(rules) + .containsExactly( + "https://example.com:8443", + "https://*.example.com:8443", + "https://other.com", + "https://*.other.com", + "https://[::1]:8080", + "https://10.0.2.2:3000", + ) + } + + @Test + fun `originRules skip blank entries and treat unbracketed IPv6 as having no port`() { + val rules = WebViewTracePropagationScript.originRules(listOf(" ", "2001:db8::1")) + + assertThat(rules).containsExactly("https://[2001:db8::1]") + } + + @Test + fun `stripTransactionFromBaggage removes sentry-transaction and keeps every other entry`() { + val baggage = + "sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b,sentry-environment=prod," + + "sentry-transaction=GET%20%2Fusers%2F123,sentry-sample_rate=1.0,othervendor=keepme" + + assertThat(WebViewTracePropagationScript.stripTransactionFromBaggage(baggage)) + .isEqualTo( + "sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b,sentry-environment=prod," + + "sentry-sample_rate=1.0,othervendor=keepme" + ) + } + + @Test + fun `stripTransactionFromBaggage returns null when only sentry-transaction is left`() { + assertThat( + WebViewTracePropagationScript.stripTransactionFromBaggage( + "sentry-transaction=GET%20%2Fusers%2F123" + ) + ) + .isNull() + assertThat(WebViewTracePropagationScript.stripTransactionFromBaggage(null)).isNull() + } + + @Test + fun `neutralizes breakout payloads in the sentry-trace value`() { + for (payload in BREAKOUT_PAYLOADS) { + val result = runInjection(script(sentryTrace = payload), "example.com") + + assertThat(result.pwned).isFalse() + assertThat(result.metas).contains(Meta("sentry-trace", payload)) + } + } + + @Test + fun `neutralizes breakout payloads in the baggage value`() { + for (payload in BREAKOUT_PAYLOADS) { + val result = runInjection(script(baggage = payload), "example.com") + + assertThat(result.pwned).isFalse() + assertThat(result.metas).contains(Meta("baggage", payload)) + } + } + + @Test + fun `neutralizes breakout payloads in allowedHosts entries`() { + for (payload in BREAKOUT_PAYLOADS) { + val script = + WebViewTracePropagationScript.build(listOf(payload, "example.com"), SENTRY_TRACE, BAGGAGE) + + val result = runInjection(script, "example.com") + + assertThat(result.pwned).isFalse() + assertThat(result.metas).hasSize(2) + } + } + + @Test + fun `preserves random sentry-trace values as inert content`() { + val chars = "\"'`\\\${}();/<>\n\r\t

abc0= ," + val random = java.util.Random(123456789) + repeat(200) { + val payload = buildString { + repeat(1 + random.nextInt(40)) { append(chars[random.nextInt(chars.length)]) } + } + + val result = runInjection(script(sentryTrace = payload), "example.com") + + assertThat(result.pwned).isFalse() + assertThat(result.metas).contains(Meta("sentry-trace", payload)) + } + } + + companion object { + private const val SENTRY_TRACE = "d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1" + private const val BAGGAGE = + "sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b,sentry-environment=prod" + + private val BREAKOUT_PAYLOADS = + listOf( + "\");window.__pwned=1;(\"", + "');window.__pwned=1;('", + "`;window.__pwned=1;`", + "\${window.__pwned=1}", + "", + "\\\";window.__pwned=1;//", + "\"}]);window.__pwned=1;//", + "\nwindow.__pwned=1;\n", + "\rwindow.__pwned=1;\r", + "
window.__pwned=1;", + "
window.__pwned=1;", + "abc\u0000;window.__pwned=1;", + ) + } +} diff --git a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index 8f479470cac..effa623db3b 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -103,6 +103,7 @@ dependencies { implementation(libs.androidx.constraintlayout) implementation(libs.androidx.core) implementation(libs.androidx.recyclerview) + implementation(libs.androidx.webkit) implementation(libs.androidx.test.espresso.idling.resource) implementation(libs.leakcanary) diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/WebViewTracePropagationTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/WebViewTracePropagationTest.kt new file mode 100644 index 00000000000..1d95819ddf4 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/WebViewTracePropagationTest.kt @@ -0,0 +1,45 @@ +package io.sentry.uitest.android + +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.launchActivity +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.webkit.WebViewFeature +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.awaitility.kotlin.await +import org.junit.Assume.assumeTrue +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class WebViewTracePropagationTest : BaseUiTest() { + + @Test + fun injectsTheTraceBeforePageScriptsAndOnlyIntoTheMainFrame() { + assumeTrue( + "The System WebView does not support document-start scripts", + WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT), + ) + initSentry { it.tracesSampleRate = 1.0 } + + val scenario = launchActivity() + scenario.moveToState(Lifecycle.State.RESUMED) + + var activity: WebViewTraceActivity? = null + scenario.onActivity { activity = it } + val webViewActivity = assertNotNull(activity) + await.atMost(30, TimeUnit.SECONDS).until { + webViewActivity.pageTrace != null && webViewActivity.iframeInjected != null + } + + assertTrue(webViewActivity.installed) + assertNotNull(webViewActivity.nativeTrace) + // Read by the page's first synchronous script: present, so there is no race. + assertEquals(webViewActivity.nativeTrace, webViewActivity.pageTrace) + assertFalse(webViewActivity.iframeInjected!!) + scenario.close() + } +} diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml b/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml index 6bcad62e6de..624520b5b4c 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml +++ b/sentry-android-integration-tests/sentry-uitest-android/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ + diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/main/assets/webview/iframe.html b/sentry-android-integration-tests/sentry-uitest-android/src/main/assets/webview/iframe.html new file mode 100644 index 00000000000..05f42a95bbb --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/main/assets/webview/iframe.html @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/main/assets/webview/index.html b/sentry-android-integration-tests/sentry-uitest-android/src/main/assets/webview/index.html new file mode 100644 index 00000000000..ddccd20a972 --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/main/assets/webview/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/WebViewTraceActivity.kt b/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/WebViewTraceActivity.kt new file mode 100644 index 00000000000..d0a9abee52c --- /dev/null +++ b/sentry-android-integration-tests/sentry-uitest-android/src/main/java/io/sentry/uitest/android/WebViewTraceActivity.kt @@ -0,0 +1,66 @@ +package io.sentry.uitest.android + +import android.annotation.SuppressLint +import android.os.Bundle +import android.webkit.JavascriptInterface +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.appcompat.app.AppCompatActivity +import androidx.webkit.WebViewAssetLoader +import io.sentry.Sentry +import io.sentry.android.core.SentryWebViewTracing + +/** Loads a bundled page with WebView trace propagation and records what the page received. */ +class WebViewTraceActivity : AppCompatActivity() { + + /** The trace the app injected. */ + @Volatile var nativeTrace: String? = null + + /** Whether addTracePropagation installed its script. */ + @Volatile var installed: Boolean = false + + /** The sentry-trace meta tag seen by the page's first synchronous script, empty if missing. */ + @Volatile var pageTrace: String? = null + + /** Whether the iframe on the page saw a sentry-trace meta tag. */ + @Volatile var iframeInjected: Boolean? = null + + @SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val webView = WebView(this) + setContentView(webView) + + val assetLoader = + WebViewAssetLoader.Builder() + .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(this)) + .build() + webView.webViewClient = + object : WebViewClient() { + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest, + ): WebResourceResponse? = assetLoader.shouldInterceptRequest(request.url) + } + webView.settings.javaScriptEnabled = true + webView.addJavascriptInterface(PageBridge(), "SentryTest") + + nativeTrace = Sentry.getTraceparent()?.value + installed = SentryWebViewTracing.addTracePropagation(webView, WebViewAssetLoader.DEFAULT_DOMAIN) + webView.loadUrl("https://${WebViewAssetLoader.DEFAULT_DOMAIN}/assets/webview/index.html") + } + + private inner class PageBridge { + @JavascriptInterface + fun onPageTrace(trace: String) { + pageTrace = trace + } + + @JavascriptInterface + fun onIframeTrace(injected: Boolean) { + iframeInjected = injected + } + } +} diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index a9d4ea8ac5f..eed60e4482a 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -210,6 +210,7 @@ dependencies { implementation(libs.androidx.recyclerview) implementation(libs.androidx.viewpager2) implementation(libs.androidx.browser) + implementation(libs.androidx.webkit) implementation(libs.androidx.room3.runtime) implementation(libs.bundles.androidx.room2) implementation(libs.bundles.androidx.sqlite.drivers) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index da8b00d97a0..7a42a1a4ace 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -97,6 +97,11 @@ android:name=".CustomTabsActivity" android:exported="false" /> + + diff --git a/sentry-samples/sentry-samples-android/src/main/assets/webview/index.html b/sentry-samples/sentry-samples-android/src/main/assets/webview/index.html new file mode 100644 index 00000000000..889ef2fb270 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/assets/webview/index.html @@ -0,0 +1,44 @@ + + + + + + + + + + +

WebView page

+

Injected sentry-trace:

+
(none, not injected)
+ + + diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt index 9660f52ed36..76b6a69f3fb 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.kt @@ -568,6 +568,16 @@ fun TracingScreen() { } } } + item { + SentryTraced("open_webview_activity") { + OutlinedButton( + onClick = { activity.startActivity(Intent(activity, WebViewActivity::class.java)) }, + modifier = Modifier, + ) { + Text("Open WebView Trace Activity", maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + } } } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/WebViewActivity.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/WebViewActivity.kt new file mode 100644 index 00000000000..b0feb66a0e6 --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/WebViewActivity.kt @@ -0,0 +1,129 @@ +package io.sentry.samples.android + +import android.annotation.SuppressLint +import android.graphics.Typeface +import android.os.Bundle +import android.webkit.JavascriptInterface +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.webkit.WebViewAssetLoader +import io.sentry.Sentry +import io.sentry.android.core.SentryWebViewTracing + +/** + * Connects the app's trace with a page shown in a WebView. + * + * The page is bundled in `assets/webview` and served over https by [WebViewAssetLoader], so its + * host can be allowed like any other. It runs `@sentry/browser`, which continues the injected trace + * on pageload, and reports back the `sentry-trace` meta tag its first synchronous `` script + * saw, so the connection can be checked on the device. + */ +class WebViewActivity : AppCompatActivity() { + + private lateinit var receivedView: TextView + private lateinit var statusView: TextView + + private var nativeTrace: String? = null + private var pageTrace: String? = null + private var installed = false + + @SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val nativeTraceView = valueView() + receivedView = valueView() + statusView = TextView(this).apply { setTypeface(typeface, Typeface.BOLD) } + val webView = WebView(this) + + setContentView( + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding(32, systemBars.top + 32, 32, systemBars.bottom) + insets + } + addView(labelView("App trace")) + addView(nativeTraceView) + addView(labelView("WebView received")) + addView(receivedView) + addView(statusView) + addView(webView, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)) + } + ) + + val assetLoader = + WebViewAssetLoader.Builder() + .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(this)) + .build() + webView.webViewClient = + object : WebViewClient() { + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest, + ): WebResourceResponse? = assetLoader.shouldInterceptRequest(request.url) + } + webView.settings.javaScriptEnabled = true + // Only the bundled demo page is loaded, so exposing this bridge to it is fine for a sample. + webView.addJavascriptInterface(PageBridge(), "SentrySample") + + // The trace that addTracePropagation injects, read right before it takes its snapshot. + nativeTrace = Sentry.getTraceparent()?.value + nativeTraceView.text = nativeTrace ?: "(no trace)" + + // Before loadUrl: the script applies to documents that start loading after this call. + installed = SentryWebViewTracing.addTracePropagation(webView, ALLOWED_HOST) + updateStatus() + + webView.loadUrl(PAGE_URL) + } + + private fun updateStatus() { + receivedView.text = pageTrace?.ifEmpty { "(none, not injected)" } ?: "(waiting…)" + statusView.text = + when { + !installed -> "Trace propagation not installed, see the Sentry logs" + pageTrace != null && pageTrace == nativeTrace -> "WebView trace connected ✓" + else -> "WebView trace not connected" + } + } + + private fun labelView(text: String): TextView = + TextView(this).apply { + this.text = text + setTypeface(typeface, Typeface.BOLD) + setPadding(0, 16, 0, 0) + } + + private fun valueView(): TextView = + TextView(this).apply { + typeface = Typeface.MONOSPACE + setTextIsSelectable(true) + } + + /** Called by the page's scripts, on a background thread. */ + private inner class PageBridge { + @JavascriptInterface fun dsn(): String? = Sentry.getCurrentScopes().options.dsn + + @JavascriptInterface + fun onPageTrace(trace: String) { + runOnUiThread { + pageTrace = trace + updateStatus() + } + } + } + + private companion object { + const val ALLOWED_HOST = WebViewAssetLoader.DEFAULT_DOMAIN + const val PAGE_URL = "https://$ALLOWED_HOST/assets/webview/index.html" + } +}