Skip to content

Commit ef64abf

Browse files
alexreptyAlvaroBrey
authored andcommitted
feat(paywalls): isolate web_view content with a fixed CSP
When a `web_view` declares a `protocol_version`, the renderer injects a fixed Content-Security-Policy meta tag at document start to isolate the content from external sources: only same-origin images/scripts/fonts are allowed, `data:` references for images/fonts are blocked, and XHR/fetch/WebSocket are disallowed (`connect-src 'none'`). Geolocation is disabled. The bundle must be fully self-contained. Recommended labels: pr:other, pr:RevenueCatUI, feat:Paywalls_V2
1 parent 4fb9844 commit ef64abf

6 files changed

Lines changed: 160 additions & 2 deletions

File tree

ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactory.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,7 @@ internal class StyleFactory(
589589
urlTemplate = component.url,
590590
visible = component.visible ?: DEFAULT_VISIBILITY,
591591
size = component.size,
592+
protocolVersion = component.protocolVersion,
592593
),
593594
)
594595

ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/WebViewComponentStyle.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,10 @@ internal data class WebViewComponentStyle(
1010
val urlTemplate: String,
1111
override val visible: Boolean,
1212
override val size: Size,
13+
/**
14+
* The schema-declared `protocol_version`. When present, the SDK isolates the web content from
15+
* external sources by enforcing a fixed Content-Security-Policy. `null` for legacy/partial configs
16+
* that omit it, in which case no policy is enforced.
17+
*/
18+
val protocolVersion: Int? = null,
1319
) : ComponentStyle

ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewComponentView.kt

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
package com.revenuecat.purchases.ui.revenuecatui.components.webview
44

5+
import android.graphics.Bitmap
56
import android.graphics.Color
67
import android.webkit.WebResourceRequest
78
import android.webkit.WebSettings
@@ -36,6 +37,10 @@ internal fun WebViewComponentView(
3637
// always resolve; render nothing rather than crashing if it doesn't.
3738
if (resolvedUrl == null) return
3839

40+
// For v1, the presence of a protocol_version means the web content is isolated from external
41+
// sources via a fixed Content-Security-Policy. Legacy configs without it get no policy.
42+
val enforceContentSecurityPolicy = style.protocolVersion != null
43+
3944
// Key on the resolved URL so the WebView is created (and the page loaded) exactly once per intended
4045
// URL. We deliberately do NOT reload on every recomposition: in-page navigation changes WebView.url,
4146
// and reloading whenever it differs from resolvedUrl would reset a multi-step web flow. The WebView
@@ -44,7 +49,7 @@ internal fun WebViewComponentView(
4449
AndroidView(
4550
factory = { context ->
4651
WebView(context).apply {
47-
configure()
52+
configure(enforceContentSecurityPolicy)
4853
loadUrl(resolvedUrl.toString())
4954
}
5055
},
@@ -58,7 +63,7 @@ internal fun WebViewComponentView(
5863
}
5964
}
6065

61-
private fun WebView.configure() {
66+
private fun WebView.configure(enforceContentSecurityPolicy: Boolean) {
6267
setBackgroundColor(Color.TRANSPARENT)
6368
isVerticalScrollBarEnabled = false
6469
isHorizontalScrollBarEnabled = false
@@ -68,7 +73,17 @@ private fun WebView.configure() {
6873
settings.domStorageEnabled = true
6974
settings.javaScriptEnabled = true
7075
settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
76+
settings.setGeolocationEnabled(false)
7177
webViewClient = object : WebViewClient() {
78+
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
79+
super.onPageStarted(view, url, favicon)
80+
// Install the isolation Content-Security-Policy before any of the page's own resources or
81+
// scripts run.
82+
if (enforceContentSecurityPolicy) {
83+
view.evaluateJavascript(contentSecurityPolicyMetaScript(), null)
84+
}
85+
}
86+
7287
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
7388
return request.url.scheme != HTTPS_SCHEME
7489
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
@file:JvmSynthetic
2+
3+
package com.revenuecat.purchases.ui.revenuecatui.components.webview
4+
5+
/**
6+
* Fixed Content-Security-Policy used to isolate Paywalls V2 `web_view` content from external sources
7+
* (v1 behavior, applied whenever the component declares a `protocol_version`). The bundle must be
8+
* fully self-contained: it may use its own packaged and inlined resources, but cannot reach any
9+
* external origin.
10+
*
11+
* - `img-src 'self' data:` / `font-src 'self' data:`: same-origin and inlined (`data:`) images/fonts
12+
* are allowed (self-contained bundles routinely inline assets); remote images/fonts are blocked.
13+
* - `script-src 'self'`: remote scripts are blocked, same-origin scripts are allowed. `'unsafe-inline'`
14+
* / `'unsafe-eval'` keep inline/eval'd first-party scripts working — the goal here is network
15+
* isolation, not locking down the (trusted) bundle's own inline code. The native bridge runs via
16+
* `evaluateJavascript`, which is privileged and unaffected by CSP.
17+
* - `connect-src 'self'`: XHR/fetch/WebSocket are allowed to the bundle's own origin only (so it can
18+
* load its packaged JSON — e.g. Lottie animation data); cross-origin requests are blocked.
19+
* - `default-src 'self'`: anchors every other resource type to same-origin (no remote).
20+
*
21+
* These functions are pure so the policy and its injection wrapper can be unit tested without an
22+
* Android `WebView`.
23+
*/
24+
internal const val WEB_VIEW_CONTENT_SECURITY_POLICY: String =
25+
"default-src 'self'; " +
26+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
27+
"style-src 'self' 'unsafe-inline'; " +
28+
"img-src 'self' data:; " +
29+
"font-src 'self' data:; " +
30+
"connect-src 'self'"
31+
32+
/**
33+
* Builds the document-start script that installs [policy] as a `<meta http-equiv>` Content-Security-
34+
* Policy. Injected from `WebViewClient.onPageStarted` (before the page's own scripts run) and inserted
35+
* as the first child of `<head>` so it precedes any resource-loading markup. The install is idempotent
36+
* via a window flag, so re-injection on redirects is a no-op.
37+
*/
38+
internal fun contentSecurityPolicyMetaScript(policy: String = WEB_VIEW_CONTENT_SECURITY_POLICY): String =
39+
"""
40+
(function() {
41+
if (window.__revenueCatCspInstalled) { return; }
42+
window.__revenueCatCspInstalled = true;
43+
var meta = document.createElement('meta');
44+
meta.setAttribute('http-equiv', 'Content-Security-Policy');
45+
meta.setAttribute('content', "${policy.escapeForMetaContent()}");
46+
var head = document.head || document.getElementsByTagName('head')[0] || document.documentElement;
47+
if (head.firstChild) { head.insertBefore(meta, head.firstChild); } else { head.appendChild(meta); }
48+
})();
49+
""".trimIndent()
50+
51+
/**
52+
* The policy is embedded inside a double-quoted JS string literal. Escape backslashes and double
53+
* quotes so a policy value can never break out of the literal. The canonical policy contains neither,
54+
* but this keeps the wrapper safe for any caller-supplied [policy].
55+
*/
56+
private fun String.escapeForMetaContent(): String =
57+
replace("\\", "\\\\").replace("\"", "\\\"")

ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/style/StyleFactoryTests.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,21 @@ class StyleFactoryTests {
136136
assertThat(style.urlTemplate).isEqualTo("https://paywalls.revenuecat.com/{{ custom.animal }}.html")
137137
assertThat(style.visible).isFalse()
138138
assertThat(style.size).isEqualTo(size)
139+
assertThat(style.protocolVersion).isNull()
140+
}
141+
142+
@Test
143+
fun `Should pass protocolVersion through to the WebViewComponentStyle`() {
144+
val component = WebViewComponent(
145+
url = "https://paywalls.revenuecat.com/index.html",
146+
protocolVersion = 1,
147+
)
148+
149+
val result = styleFactory.create(component)
150+
151+
assertThat(result).isInstanceOf(Result.Success::class.java)
152+
val style = (result as Result.Success).value.componentStyle as WebViewComponentStyle
153+
assertThat(style.protocolVersion).isEqualTo(1)
139154
}
140155

141156
@Test
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.revenuecat.purchases.ui.revenuecatui.components.webview
2+
3+
import org.assertj.core.api.Assertions.assertThat
4+
import org.junit.Test
5+
6+
class WebViewContentSecurityPolicyTest {
7+
8+
@Test
9+
fun `policy allows same-origin and inlined images and fonts`() {
10+
// Self-contained bundles routinely inline assets as data: URIs; remote hosts stay blocked.
11+
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("img-src 'self' data:")
12+
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("font-src 'self' data:")
13+
}
14+
15+
@Test
16+
fun `policy allows only same-origin scripts`() {
17+
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("script-src 'self'")
18+
}
19+
20+
@Test
21+
fun `policy restricts XHR and other connections to the same origin`() {
22+
// Same-origin fetch/XHR is allowed so the bundle can load its own packaged JSON (e.g. Lottie),
23+
// but cross-origin connections are not.
24+
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("connect-src 'self'")
25+
}
26+
27+
@Test
28+
fun `policy anchors everything else to same-origin via default-src`() {
29+
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("default-src 'self'")
30+
}
31+
32+
@Test
33+
fun `policy whitelists no external origins`() {
34+
// Isolation invariant: only 'self'/data:/inline keywords — never a wildcard or a remote host.
35+
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).doesNotContain("*")
36+
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).doesNotContain("http")
37+
}
38+
39+
@Test
40+
fun `meta script installs the policy as an http-equiv meta element`() {
41+
val script = contentSecurityPolicyMetaScript("default-src 'self'")
42+
43+
assertThat(script).contains("http-equiv")
44+
assertThat(script).contains("Content-Security-Policy")
45+
assertThat(script).contains("default-src 'self'")
46+
// Inserted before any other markup so it precedes resource-loading elements.
47+
assertThat(script).contains("insertBefore")
48+
}
49+
50+
@Test
51+
fun `meta script is idempotent via a window flag`() {
52+
val script = contentSecurityPolicyMetaScript()
53+
54+
assertThat(script).contains("__revenueCatCspInstalled")
55+
}
56+
57+
@Test
58+
fun `meta script escapes double quotes and backslashes in the policy`() {
59+
val script = contentSecurityPolicyMetaScript("default-src \"x\\y\"")
60+
61+
// The double quotes and backslash are escaped so they cannot break out of the JS string literal.
62+
assertThat(script).contains("""default-src \"x\\y\"""")
63+
}
64+
}

0 commit comments

Comments
 (0)