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
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,7 @@ internal class StyleFactory(
urlTemplate = component.url,
visible = component.visible ?: DEFAULT_VISIBILITY,
size = component.size,
protocolVersion = component.protocolVersion,
),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -36,6 +37,10 @@ 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

// Key on the resolved URL so the WebView is created (and the page loaded) exactly once per intended
// URL. We deliberately do NOT reload on every recomposition: in-page navigation changes WebView.url,
// and reloading whenever it differs from resolvedUrl would reset a multi-step web flow. The WebView
Expand All @@ -44,7 +49,7 @@ internal fun WebViewComponentView(
AndroidView(
factory = { context ->
WebView(context).apply {
configure()
configure(enforceContentSecurityPolicy)
loadUrl(resolvedUrl.toString())
}
},
Expand All @@ -58,7 +63,7 @@ internal fun WebViewComponentView(
}
}

private fun WebView.configure() {
private fun WebView.configure(enforceContentSecurityPolicy: Boolean) {
setBackgroundColor(Color.TRANSPARENT)
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
Expand All @@ -68,7 +73,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)
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
return request.url.scheme != HTTPS_SCHEME
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
@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: it may use its own packaged and inlined resources, but cannot reach any
* external origin.
*
* - `img-src 'self' data:` / `font-src 'self' data:`: same-origin and inlined (`data:`) images/fonts
* are allowed (self-contained bundles routinely inline assets); remote images/fonts are blocked.
* - `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 'self'`: XHR/fetch/WebSocket are allowed to the bundle's own origin only (so it can
* load its packaged JSON — e.g. Lottie animation data); cross-origin requests are blocked.
* - `default-src 'self'`: anchors every other resource type to same-origin (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' data:; " +
"font-src 'self' data:; " +
"connect-src 'self'"

/**
* Builds the document-start script that installs [policy] as a `<meta http-equiv>` Content-Security-
* Policy. Injected from `WebViewClient.onPageStarted` (before the page's own scripts run) and inserted
* as the first child of `<head>` 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); }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CSP meta placed outside head

High Severity

When document.head is not yet available, the injection script falls back to document.documentElement and inserts the CSP meta as a direct child of html. CSP meta tags are only applied when they live inside head, so this placement is ignored and isolation does not take effect. Setting window.__revenueCatCspInstalled on that failed attempt can also block later injections on the same navigation.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9cff885. Configure here.

})();
""".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("\"", "\\\"")
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.revenuecat.purchases.ui.revenuecatui.components.webview

import org.assertj.core.api.Assertions.assertThat
import org.junit.Test

class WebViewContentSecurityPolicyTest {

@Test
fun `policy allows same-origin and inlined images and fonts`() {
// Self-contained bundles routinely inline assets as data: URIs; remote hosts stay blocked.
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("img-src 'self' data:")
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("font-src 'self' data:")
}

@Test
fun `policy allows only same-origin scripts`() {
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("script-src 'self'")
}

@Test
fun `policy restricts XHR and other connections to the same origin`() {
// Same-origin fetch/XHR is allowed so the bundle can load its own packaged JSON (e.g. Lottie),
// but cross-origin connections are not.
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("connect-src 'self'")
}

@Test
fun `policy anchors everything else to same-origin via default-src`() {
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).contains("default-src 'self'")
}

@Test
fun `policy whitelists no external origins`() {
// Isolation invariant: only 'self'/data:/inline keywords — never a wildcard or a remote host.
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).doesNotContain("*")
assertThat(WEB_VIEW_CONTENT_SECURITY_POLICY).doesNotContain("http")
}

@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\"""")
}
}