-
Notifications
You must be signed in to change notification settings - Fork 107
feat(paywalls): isolate web_view content with a fixed CSP (PWENG-98) #3652
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: alexrepty/paywalls-web-view-rendering
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CSP meta placed outside headHigh Severity When 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 |
|---|---|---|
| @@ -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\"""") | ||
| } | ||
| } |


Uh oh!
There was an error while loading. Please reload this page.