diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt index e26a640eb..c2c4fc836 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt @@ -589,6 +589,7 @@ internal class StyleFactory( urlTemplate = component.url, visible = component.visible ?: DEFAULT_VISIBILITY, size = component.size, + protocolVersion = component.protocolVersion, ), ) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt index cfafb443d..9b4cb357a 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt @@ -10,4 +10,10 @@ internal data class WebViewComponentStyle( val urlTemplate: String, override val visible: Boolean, override val size: Size, + /** + * The schema-declared `protocol_version`. When present, the SDK isolates the web content from + * external sources by enforcing a fixed Content-Security-Policy. `null` for legacy/partial configs + * that omit it, in which case no policy is enforced. + */ + val protocolVersion: Int? = null, ) : ComponentStyle diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt index 7ed9d42ef..f65985ed4 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt @@ -2,6 +2,7 @@ package com.revenuecat.purchases.ui.revenuecatui.components.webview +import android.graphics.Bitmap import android.graphics.Color import android.webkit.WebResourceRequest import android.webkit.WebSettings @@ -32,10 +33,14 @@ internal fun WebViewComponentView( // always resolve; render nothing rather than crashing if it doesn't. if (resolvedUrl == null) return + // For v1, the presence of a protocol_version means the web content is isolated from external + // sources via a fixed Content-Security-Policy. Legacy configs without it get no policy. + val enforceContentSecurityPolicy = style.protocolVersion != null + AndroidView( factory = { context -> WebView(context).apply { - configure() + configure(enforceContentSecurityPolicy) loadUrl(resolvedUrl.toString()) } }, @@ -53,7 +58,7 @@ internal fun WebViewComponentView( ) } -private fun WebView.configure() { +private fun WebView.configure(enforceContentSecurityPolicy: Boolean) { setBackgroundColor(Color.TRANSPARENT) isVerticalScrollBarEnabled = false isHorizontalScrollBarEnabled = false @@ -63,7 +68,17 @@ private fun WebView.configure() { settings.domStorageEnabled = true settings.javaScriptEnabled = true settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + settings.setGeolocationEnabled(false) webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + // Install the isolation Content-Security-Policy before any of the page's own resources or + // scripts run. + if (enforceContentSecurityPolicy) { + view.evaluateJavascript(contentSecurityPolicyMetaScript(), null) + } + } + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { return request.url.scheme != HTTPS_SCHEME } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicy.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicy.kt new file mode 100644 index 000000000..4596505aa --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicy.kt @@ -0,0 +1,55 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +/** + * Fixed Content-Security-Policy used to isolate Paywalls V2 `web_view` content from external sources + * (v1 behavior, applied whenever the component declares a `protocol_version`). The bundle must be + * fully self-contained. + * + * - `img-src 'self'` / `font-src 'self'`: remote images/fonts are blocked, and because `data:` is not + * listed, `data:` references for images and fonts are blocked too. + * - `script-src 'self'`: remote scripts are blocked, same-origin scripts are allowed. `'unsafe-inline'` + * / `'unsafe-eval'` keep inline/eval'd first-party scripts working — the goal here is network + * isolation, not locking down the (trusted) bundle's own inline code. The native bridge runs via + * `evaluateJavascript`, which is privileged and unaffected by CSP. + * - `connect-src 'none'`: XHR/fetch/WebSocket/EventSource are disallowed entirely. + * - `default-src 'self'`: anchors every other resource type to same-origin (no `data:`, no remote). + * + * These functions are pure so the policy and its injection wrapper can be unit tested without an + * Android `WebView`. + */ +internal const val WEB_VIEW_CONTENT_SECURITY_POLICY: String = + "default-src 'self'; " + + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self'; " + + "font-src 'self'; " + + "connect-src 'none'" + +/** + * Builds the document-start script that installs [policy] as a `` Content-Security- + * Policy. Injected from `WebViewClient.onPageStarted` (before the page's own scripts run) and inserted + * as the first child of `` so it precedes any resource-loading markup. The install is idempotent + * via a window flag, so re-injection on redirects is a no-op. + */ +internal fun contentSecurityPolicyMetaScript(policy: String = WEB_VIEW_CONTENT_SECURITY_POLICY): String = + """ + (function() { + if (window.__revenueCatCspInstalled) { return; } + window.__revenueCatCspInstalled = true; + var meta = document.createElement('meta'); + meta.setAttribute('http-equiv', 'Content-Security-Policy'); + meta.setAttribute('content', "${policy.escapeForMetaContent()}"); + var head = document.head || document.getElementsByTagName('head')[0] || document.documentElement; + if (head.firstChild) { head.insertBefore(meta, head.firstChild); } else { head.appendChild(meta); } + })(); + """.trimIndent() + +/** + * The policy is embedded inside a double-quoted JS string literal. Escape backslashes and double + * quotes so a policy value can never break out of the literal. The canonical policy contains neither, + * but this keeps the wrapper safe for any caller-supplied [policy]. + */ +private fun String.escapeForMetaContent(): String = + replace("\\", "\\\\").replace("\"", "\\\"") diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt index f01a75639..412d4d403 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt @@ -136,6 +136,21 @@ class StyleFactoryTests { assertThat(style.urlTemplate).isEqualTo("https://paywalls.revenuecat.com/{{ custom.animal }}.html") assertThat(style.visible).isFalse() assertThat(style.size).isEqualTo(size) + assertThat(style.protocolVersion).isNull() + } + + @Test + fun `Should pass protocolVersion through to the WebViewComponentStyle`() { + val component = WebViewComponent( + url = "https://paywalls.revenuecat.com/index.html", + protocolVersion = 1, + ) + + val result = styleFactory.create(component) + + assertThat(result).isInstanceOf(Result.Success::class.java) + val style = (result as Result.Success).value.componentStyle as WebViewComponentStyle + assertThat(style.protocolVersion).isEqualTo(1) } @Test diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicyTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicyTest.kt new file mode 100644 index 000000000..dfb49b0ac --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewContentSecurityPolicyTest.kt @@ -0,0 +1,56 @@ +package com.revenuecat.purchases.ui.revenuecatui.components.webview + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class WebViewContentSecurityPolicyTest { + + @Test + fun `policy isolates remote and data images and fonts to same-origin only`() { + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("img-src 'self'") + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("font-src 'self'") + // No data: scheme is granted to images or fonts. + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).doesNotContain("data:") + } + + @Test + fun `policy allows only same-origin scripts`() { + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("script-src 'self'") + } + + @Test + fun `policy disallows XHR and other connections`() { + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("connect-src 'none'") + } + + @Test + fun `policy anchors everything else to same-origin via default-src`() { + assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("default-src 'self'") + } + + @Test + fun `meta script installs the policy as an http-equiv meta element`() { + val script = contentSecurityPolicyMetaScript("default-src 'self'") + + assertThat(script).contains("http-equiv") + assertThat(script).contains("Content-Security-Policy") + assertThat(script).contains("default-src 'self'") + // Inserted before any other markup so it precedes resource-loading elements. + assertThat(script).contains("insertBefore") + } + + @Test + fun `meta script is idempotent via a window flag`() { + val script = contentSecurityPolicyMetaScript() + + assertThat(script).contains("__revenueCatCspInstalled") + } + + @Test + fun `meta script escapes double quotes and backslashes in the policy`() { + val script = contentSecurityPolicyMetaScript("default-src \"x\\y\"") + + // The double quotes and backslash are escaped so they cannot break out of the JS string literal. + assertThat(script).contains("""default-src \"x\\y\"""") + } +}