Skip to content

Commit 0dffacc

Browse files
committed
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 6c73e7c commit 0dffacc

6 files changed

Lines changed: 150 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
@@ -32,10 +33,14 @@ internal fun WebViewComponentView(
3233
// always resolve; render nothing rather than crashing if it doesn't.
3334
if (resolvedUrl == null) return
3435

36+
// For v1, the presence of a protocol_version means the web content is isolated from external
37+
// sources via a fixed Content-Security-Policy. Legacy configs without it get no policy.
38+
val enforceContentSecurityPolicy = style.protocolVersion != null
39+
3540
AndroidView(
3641
factory = { context ->
3742
WebView(context).apply {
38-
configure()
43+
configure(enforceContentSecurityPolicy)
3944
loadUrl(resolvedUrl.toString())
4045
}
4146
},
@@ -53,7 +58,7 @@ internal fun WebViewComponentView(
5358
)
5459
}
5560

56-
private fun WebView.configure() {
61+
private fun WebView.configure(enforceContentSecurityPolicy: Boolean) {
5762
setBackgroundColor(Color.TRANSPARENT)
5863
isVerticalScrollBarEnabled = false
5964
isHorizontalScrollBarEnabled = false
@@ -63,7 +68,17 @@ private fun WebView.configure() {
6368
settings.domStorageEnabled = true
6469
settings.javaScriptEnabled = true
6570
settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
71+
settings.setGeolocationEnabled(false)
6672
webViewClient = object : WebViewClient() {
73+
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
74+
super.onPageStarted(view, url, favicon)
75+
// Install the isolation Content-Security-Policy before any of the page's own resources or
76+
// scripts run.
77+
if (enforceContentSecurityPolicy) {
78+
view.evaluateJavascript(contentSecurityPolicyMetaScript(), null)
79+
}
80+
}
81+
6782
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
6883
return request.url.scheme != HTTPS_SCHEME
6984
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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.
9+
*
10+
* - `img-src 'self'` / `font-src 'self'`: remote images/fonts are blocked, and because `data:` is not
11+
* listed, `data:` references for images and fonts are blocked too.
12+
* - `script-src 'self'`: remote scripts are blocked, same-origin scripts are allowed. `'unsafe-inline'`
13+
* / `'unsafe-eval'` keep inline/eval'd first-party scripts working — the goal here is network
14+
* isolation, not locking down the (trusted) bundle's own inline code. The native bridge runs via
15+
* `evaluateJavascript`, which is privileged and unaffected by CSP.
16+
* - `connect-src 'none'`: XHR/fetch/WebSocket/EventSource are disallowed entirely.
17+
* - `default-src 'self'`: anchors every other resource type to same-origin (no `data:`, no remote).
18+
*
19+
* These functions are pure so the policy and its injection wrapper can be unit tested without an
20+
* Android `WebView`.
21+
*/
22+
internal const val WEB_VIEW_CONTENT_SECURITY_POLICY: String =
23+
"default-src 'self'; " +
24+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
25+
"style-src 'self' 'unsafe-inline'; " +
26+
"img-src 'self'; " +
27+
"font-src 'self'; " +
28+
"connect-src 'none'"
29+
30+
/**
31+
* Builds the document-start script that installs [policy] as a `<meta http-equiv>` Content-Security-
32+
* Policy. Injected from `WebViewClient.onPageStarted` (before the page's own scripts run) and inserted
33+
* as the first child of `<head>` so it precedes any resource-loading markup. The install is idempotent
34+
* via a window flag, so re-injection on redirects is a no-op.
35+
*/
36+
internal fun contentSecurityPolicyMetaScript(policy: String = WEB_VIEW_CONTENT_SECURITY_POLICY): String =
37+
"""
38+
(function() {
39+
if (window.__revenueCatCspInstalled) { return; }
40+
window.__revenueCatCspInstalled = true;
41+
var meta = document.createElement('meta');
42+
meta.setAttribute('http-equiv', 'Content-Security-Policy');
43+
meta.setAttribute('content', "${policy.escapeForMetaContent()}");
44+
var head = document.head || document.getElementsByTagName('head')[0] || document.documentElement;
45+
if (head.firstChild) { head.insertBefore(meta, head.firstChild); } else { head.appendChild(meta); }
46+
})();
47+
""".trimIndent()
48+
49+
/**
50+
* The policy is embedded inside a double-quoted JS string literal. Escape backslashes and double
51+
* quotes so a policy value can never break out of the literal. The canonical policy contains neither,
52+
* but this keeps the wrapper safe for any caller-supplied [policy].
53+
*/
54+
private fun String.escapeForMetaContent(): String =
55+
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,56 @@
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 isolates remote and data images and fonts to same-origin only`() {
10+
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("img-src 'self'")
11+
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("font-src 'self'")
12+
// No data: scheme is granted to images or fonts.
13+
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).doesNotContain("data:")
14+
}
15+
16+
@Test
17+
fun `policy allows only same-origin scripts`() {
18+
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("script-src 'self'")
19+
}
20+
21+
@Test
22+
fun `policy disallows XHR and other connections`() {
23+
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("connect-src 'none'")
24+
}
25+
26+
@Test
27+
fun `policy anchors everything else to same-origin via default-src`() {
28+
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("default-src 'self'")
29+
}
30+
31+
@Test
32+
fun `meta script installs the policy as an http-equiv meta element`() {
33+
val script = contentSecurityPolicyMetaScript("default-src 'self'")
34+
35+
assertThat(script).contains("http-equiv")
36+
assertThat(script).contains("Content-Security-Policy")
37+
assertThat(script).contains("default-src 'self'")
38+
// Inserted before any other markup so it precedes resource-loading elements.
39+
assertThat(script).contains("insertBefore")
40+
}
41+
42+
@Test
43+
fun `meta script is idempotent via a window flag`() {
44+
val script = contentSecurityPolicyMetaScript()
45+
46+
assertThat(script).contains("__revenueCatCspInstalled")
47+
}
48+
49+
@Test
50+
fun `meta script escapes double quotes and backslashes in the policy`() {
51+
val script = contentSecurityPolicyMetaScript("default-src \"x\\y\"")
52+
53+
// The double quotes and backslash are escaped so they cannot break out of the JS string literal.
54+
assertThat(script).contains("""default-src \"x\\y\"""")
55+
}
56+
}

0 commit comments

Comments
 (0)