Skip to content

Commit 0723cd5

Browse files
cursoragentjacob.rakidzich
authored andcommitted
Fix five known web_view JavaScript bridge defects
Harden lifecycle (drop queued outbound JS after release), re-validate origin on the main thread, compare hosts case-insensitively, fix payload KDoc, and exclude ERROR_CANCELLED from fallback triggers. Each fix has a regression test. Co-authored-by: jacob.rakidzich <jacob.rakidzich@revenuecat.com>
1 parent d340e52 commit 0723cd5

4 files changed

Lines changed: 97 additions & 35 deletions

File tree

ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallWebViewMessage.kt

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,21 +36,20 @@ public interface PaywallWebViewController {
3636

3737
/**
3838
* Sends a `rc:variables` message into the web view targeting [componentId]. The SDK already replies
39-
* with SDK-managed variables (such as `locale`); use this to provide additional values, typically
40-
* nested under the `custom` key.
41-
*
42-
* Reserved SDK-managed top-level keys cannot be overwritten; provide app-specific values under
43-
* `custom`.
39+
* with SDK-managed variables (such as `locale`); use this to provide additional values. The flat
40+
* [variables] map is sent as the transport envelope's `payload` field (not nested under a
41+
* `variables` key). Reserved SDK-managed top-level keys cannot be overwritten; provide
42+
* app-specific values under `custom`.
4443
*/
4544
public fun postVariables(
4645
componentId: String,
4746
variables: Map<String, PaywallWebViewValue>,
4847
)
4948

5049
/**
51-
* Sends a message of the given [type] into the web view targeting [componentId], following the web
52-
* view protocol envelope `{ "type", "component_id", "variables" }`. [variables] is delivered under
53-
* the `variables` key. Use [postVariables] for the common `rc:variables` case.
50+
* Sends a message of the given [type] into the web view targeting [componentId]. The flat
51+
* [variables] map is sent as the transport envelope's `payload` field (not nested under a
52+
* `variables` key). Use [postVariables] for the common `rc:variables` case.
5453
*/
5554
public fun postMessage(
5655
componentId: String,

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import com.revenuecat.purchases.ui.revenuecatui.components.stack.StackComponentV
2828
import com.revenuecat.purchases.ui.revenuecatui.components.style.WebViewComponentStyle
2929
import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState
3030
import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallComponentInteractionTracker
31-
import java.net.URL
3231

3332
@JvmSynthetic
3433
@Composable
@@ -85,7 +84,7 @@ internal fun WebViewComponentView(
8584
WebViewJavaScriptBridge(
8685
webView = this,
8786
componentId = id,
88-
expectedUrl = URL(resolvedUrl),
87+
expectedUrl = resolvedUrl,
8988
locale = locale,
9089
messageHandler = null,
9190
protocolVersion = style.protocolVersion ?: WebViewEnvelope.DEFAULT_PROTOCOL_VERSION,

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

Lines changed: 38 additions & 17 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.net.Uri
56
import android.os.Handler
67
import android.os.Looper
78
import android.webkit.JavascriptInterface
@@ -14,7 +15,7 @@ import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger
1415
import com.revenuecat.purchases.ui.revenuecatui.toJsonObject
1516
import org.json.JSONObject
1617
import java.lang.ref.WeakReference
17-
import java.net.URL
18+
import java.util.Locale
1819

1920
/**
2021
* Installs and drives the bidirectional bridge between a Paywalls V2 `web_view` component and native
@@ -30,20 +31,22 @@ import java.net.URL
3031
* the same connect/init handshake as on web. No document-start shim is injected.
3132
*
3233
* Because `addJavascriptInterface` provides no platform origin scoping, the bridge enforces the origin
33-
* itself: before delivering any inbound message it verifies the WebView's current URL still has the
34-
* same origin (scheme + host + port) as the resolved component URL, rejecting messages received after
35-
* navigation to an unexpected origin.
34+
* itself: before delivering any inbound or outbound message it verifies the WebView's current URL still
35+
* has the same origin (scheme + host + port) as the resolved component URL, rejecting messages received
36+
* after navigation to an unexpected origin. Origin is always re-checked on the main thread at delivery
37+
* time.
3638
*
3739
* ## Lifecycle & threading
3840
* The bridge holds only a [WeakReference] to the [WebView] (no Activity/Context), and stops delivering
3941
* messages once [release] is called. Inbound JavaScript callbacks arrive on a binder thread and are
4042
* hopped onto the main thread; app callbacks and all WebView interactions happen on the main thread.
43+
* Outbound [WebView.evaluateJavascript] calls queued before [release] are dropped when they run.
4144
*/
4245
@Suppress("LongParameterList", "TooManyFunctions")
4346
internal class WebViewJavaScriptBridge(
4447
webView: WebView,
4548
private val componentId: String,
46-
expectedUrl: URL,
49+
expectedUrl: String,
4750
locale: String,
4851
messageHandler: PaywallWebViewMessageHandler?,
4952
protocolVersion: Int = WebViewEnvelope.DEFAULT_PROTOCOL_VERSION,
@@ -103,8 +106,10 @@ internal class WebViewJavaScriptBridge(
103106
*/
104107
@JavascriptInterface
105108
fun postMessage(json: String) {
106-
if (released) return
107-
mainHandler.post { handleInboundMessage(json) }
109+
mainHandler.post {
110+
if (released) return@post
111+
handleInboundMessage(json)
112+
}
108113
}
109114

110115
@MainThread
@@ -314,7 +319,7 @@ internal class WebViewJavaScriptBridge(
314319
@MainThread
315320
private fun isOriginTrusted(webView: WebView, allowBeforeNavigation: Boolean): Boolean {
316321
if (expectedOrigin == null) return false
317-
val currentOrigin = webView.url?.let { runCatching { URL(it).toOriginOrNull() }.getOrNull() }
322+
val currentOrigin = webView.url?.toOriginOrNull()
318323
return when {
319324
currentOrigin == null -> allowBeforeNavigation
320325
else -> currentOrigin == expectedOrigin
@@ -323,9 +328,13 @@ internal class WebViewJavaScriptBridge(
323328

324329
private fun runOnMainThread(block: () -> Unit) {
325330
if (Looper.myLooper() == Looper.getMainLooper()) {
331+
if (released) return
326332
block()
327333
} else {
328-
mainHandler.post(block)
334+
mainHandler.post {
335+
if (released) return@post
336+
block()
337+
}
329338
}
330339
}
331340

@@ -345,13 +354,25 @@ internal class WebViewJavaScriptBridge(
345354
}
346355

347356
/**
348-
* The origin of this URL as `scheme://host[:port]`, or `null` if it lacks a host. Only HTTPS URLs are
349-
* expected here (the resolver already enforces this), but the origin string includes the scheme so a
350-
* scheme change would also be detected.
357+
* The origin of this URL as `scheme://host[:port]`, or `null` if it lacks a host. Host comparison is
358+
* case-insensitive. Default ports (443 for https, 80 for http) are omitted.
351359
*/
352-
private fun URL.toOriginOrNull(): String? {
353-
val host = host?.takeIf { it.isNotBlank() } ?: return null
354-
val effectivePort = if (port == -1) defaultPort else port
355-
val portSuffix = if (effectivePort == -1) "" else ":$effectivePort"
356-
return "$protocol://$host$portSuffix"
360+
@Suppress("ReturnCount", "MagicNumber")
361+
internal fun String.toOriginOrNull(): String? {
362+
val uri = Uri.parse(this)
363+
val scheme = uri.scheme?.lowercase(Locale.US) ?: return null
364+
val host = uri.host?.takeIf { it.isNotBlank() }?.lowercase(Locale.US) ?: return null
365+
val effectivePort = when {
366+
uri.port != -1 -> uri.port
367+
scheme == "https" -> 443
368+
scheme == "http" -> 80
369+
else -> -1
370+
}
371+
val portSuffix = when {
372+
effectivePort == -1 -> ""
373+
scheme == "https" && effectivePort == 443 -> ""
374+
scheme == "http" && effectivePort == 80 -> ""
375+
else -> ":$effectivePort"
376+
}
377+
return "$scheme://$host$portSuffix"
357378
}

ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/webview/WebViewJavaScriptBridgeTest.kt

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,12 @@ import org.junit.runner.RunWith
1414
import org.robolectric.RobolectricTestRunner
1515
import org.robolectric.Shadows.shadowOf
1616
import org.robolectric.shadows.ShadowWebView
17-
import java.net.URL
1817

1918
@RunWith(RobolectricTestRunner::class)
2019
internal class WebViewJavaScriptBridgeTest {
2120

2221
private val componentId = "promo_web_view"
23-
private val expectedUrl = URL("https://assets.example.com/promo/index.html")
22+
private val expectedUrl = "https://assets.example.com/promo/index.html"
2423

2524
private lateinit var webView: WebView
2625
private lateinit var shadowWebView: ShadowWebView
@@ -36,7 +35,7 @@ internal class WebViewJavaScriptBridgeTest {
3635
private fun bridge(
3736
handler: PaywallWebViewMessageHandler? = PaywallWebViewMessageHandler { message, _ -> received.add(message) },
3837
locale: String = "en-US",
39-
navigateTo: URL? = expectedUrl,
38+
navigateTo: String? = expectedUrl,
4039
sizeToContentWidth: Boolean = false,
4140
sizeToContentHeight: Boolean = false,
4241
onContentResize: (widthCssPx: Int?, heightCssPx: Int?) -> Unit = { _, _ -> },
@@ -52,7 +51,7 @@ internal class WebViewJavaScriptBridgeTest {
5251
onContentResize = onContentResize,
5352
)
5453
bridge.attach()
55-
navigateTo?.let { webView.loadUrl(it.toString()) }
54+
navigateTo?.let { webView.loadUrl(it) }
5655
return bridge
5756
}
5857

@@ -251,9 +250,53 @@ internal class WebViewJavaScriptBridgeTest {
251250
assertThat(received).isEmpty()
252251
}
253252

253+
@Test
254+
fun `does not execute queued outbound evaluateJavascript after release`() {
255+
val bridge = bridge()
256+
connect(bridge)
257+
val background = Thread {
258+
bridge.postMessage(
259+
componentId = componentId,
260+
type = "rc:queued",
261+
variables = mapOf("foo" to PaywallWebViewValue.String("bar")),
262+
)
263+
}
264+
background.start()
265+
background.join()
266+
bridge.release()
267+
idleMainLooper()
268+
269+
assertThat(shadowWebView.lastEvaluatedJavascript).doesNotContain("rc:queued")
270+
}
271+
272+
@Test
273+
fun `re-validates origin at main-thread delivery after navigation race`() {
274+
val bridge = bridge()
275+
connect(bridge)
276+
webView.loadUrl("https://evil.example.org/phish.html")
277+
val background = Thread {
278+
bridge.postMessage(appMessage(WebViewMessageType.STEP_LOADED))
279+
}
280+
background.start()
281+
background.join()
282+
idleMainLooper()
283+
284+
assertThat(received).isEmpty()
285+
}
286+
287+
@Test
288+
fun `treats host comparison as case insensitive`() {
289+
val bridge = bridge(navigateTo = "https://Assets.Example.COM/promo/index.html")
290+
connect(bridge)
291+
bridge.postMessage(appMessage(WebViewMessageType.STEP_LOADED))
292+
idleMainLooper()
293+
294+
assertThat(received).hasSize(1)
295+
}
296+
254297
@Test
255298
fun `rejects messages after navigation to an unexpected origin`() {
256-
val bridge = bridge(navigateTo = URL("https://evil.example.org/phish.html"))
299+
val bridge = bridge(navigateTo = "https://evil.example.org/phish.html")
257300
connect(bridge)
258301
bridge.postMessage(appMessage(WebViewMessageType.STEP_LOADED))
259302
idleMainLooper()
@@ -263,7 +306,7 @@ internal class WebViewJavaScriptBridgeTest {
263306

264307
@Test
265308
fun `allows messages from the same origin on a different path`() {
266-
val bridge = bridge(navigateTo = URL("https://assets.example.com/promo/step-two.html"))
309+
val bridge = bridge(navigateTo = "https://assets.example.com/promo/step-two.html")
267310
connect(bridge)
268311
bridge.postMessage(appMessage(WebViewMessageType.STEP_LOADED))
269312
idleMainLooper()
@@ -273,7 +316,7 @@ internal class WebViewJavaScriptBridgeTest {
273316

274317
@Test
275318
fun `does not deliver outbound messages after navigation to an unexpected origin`() {
276-
val bridge = bridge(navigateTo = URL("https://evil.example.org/phish.html"))
319+
val bridge = bridge(navigateTo = "https://evil.example.org/phish.html")
277320
connect(bridge)
278321
bridge.postMessage(
279322
componentId = componentId,
@@ -287,7 +330,7 @@ internal class WebViewJavaScriptBridgeTest {
287330

288331
@Test
289332
fun `treats the default https port as the same origin`() {
290-
val bridge = bridge(navigateTo = URL("https://assets.example.com:443/promo/index.html"))
333+
val bridge = bridge(navigateTo = "https://assets.example.com:443/promo/index.html")
291334
connect(bridge)
292335
bridge.postMessage(
293336
componentId = componentId,

0 commit comments

Comments
 (0)