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 @@ -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())
}
},
Expand All @@ -53,7 +58,7 @@ internal fun WebViewComponentView(
)
}

private fun WebView.configure() {
private fun WebView.configure(enforceContentSecurityPolicy: Boolean) {
setBackgroundColor(Color.TRANSPARENT)
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 `<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); }
})();
""".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,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\"""")
}
}