Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions sentry-android-core/api/sentry-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)}.
*
Expand Down Expand Up @@ -96,6 +118,15 @@ static boolean addTracePropagation(
final @NotNull List<String> 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<String> 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);
Expand All @@ -109,14 +140,28 @@ 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(
final @NotNull WebView webView,
final @NotNull List<String> 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<String> 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.
Expand All @@ -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.
Expand All @@ -150,15 +196,18 @@ private static boolean replaceScript(
final @NotNull List<String> 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);

final @NotNull List<String> hosts = WebViewTracePropagationScript.normalizeHosts(allowedHosts);
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;
}

Expand Down Expand Up @@ -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.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> hostsFromTracePropagationTargets(
final @NotNull Collection<String> targets) {
final @NotNull List<String> 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.
Expand Down Expand Up @@ -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<String> originRules(final @NotNull Collection<String> 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<String> originRules(
final @NotNull Collection<String> allowedHosts, final boolean fromTracePropagationTargets) {
final @NotNull Set<String> rules = new LinkedHashSet<>();
for (final @Nullable String entry : allowedHosts) {
if (entry == null) {
Expand All @@ -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("");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Set<String>>()
mockStatic(WebViewCompat::class.java).use { webViewCompat ->
mockStatic(WebViewFeature::class.java).use { webViewFeature ->
webViewFeature
.`when`<Boolean> {
WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
}
.thenReturn(true)
webViewCompat
.`when`<ScriptHandler> {
WebViewCompat.addDocumentStartJavaScript(eq(webView), any(), origins.capture())
}
.thenReturn(mock<ScriptHandler>())

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<ScriptHandler>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading