Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
bc7e0b8
Add HostVerifier with a same-origin default
Aug 27, 2026
55a4032
Enforce host verification in navigation and bridge decisions
Aug 27, 2026
42ea8f1
Gate the Turbo JS interface, replies, and permission grants on truste…
Aug 27, 2026
3b7a43e
Cancel HTTP auth challenges from untrusted pages
Aug 27, 2026
66d779b
Make host-verification block logs descriptive and consistent
Aug 27, 2026
339b500
Register navigator start locations centrally; drop the anchor parameter
Aug 27, 2026
3478401
Surface a blocked bridge installation as LoadError.UntrustedOrigin
Aug 31, 2026
d67e64f
Compact the host-verification KDoc
Aug 31, 2026
3f3063d
Replace JavascriptInterface with origin-aware WebMessageListener chan…
Sep 1, 2026
a9c6a3d
Gate HTTP auth challenges on the challenged host
Sep 1, 2026
350e542
Gate the file chooser and re-verify origins on async permission grants
Sep 1, 2026
6a84c6b
Compare full origins when sanitizing the deep-link start location
Sep 1, 2026
a3193d9
Fail closed when gating bridge operations without a document
Sep 1, 2026
e167ee2
Make VisitError.description() an extension to fix R8 release bytecode
Sep 1, 2026
5565e29
Re-verify the page when the file picker returns and answer held requests
Sep 1, 2026
211f0a9
Surface LoadError.WebViewNotSupported instead of hanging on old WebViews
Sep 1, 2026
0be5449
Replace fully qualified names with imports in the delegate and sessio…
Sep 1, 2026
3aecee0
Compact the comments added with the message-channel hardening
Sep 1, 2026
aad670e
Scope trusted-location registrations to the navigator host lifecycle
Sep 2, 2026
c0588c1
Make VisitError.description an abstract property
Sep 14, 2026
cd74399
Forward HTTP auth challenges to the app unchecked
Sep 14, 2026
3f19b79
Replace HostVerifier with OriginTrustPolicy over a typed Origin
Sep 14, 2026
871df63
Carry the blocked location in LoadError.UntrustedOrigin
Sep 14, 2026
41d80b1
Construct Origin only by parsing
Sep 23, 2026
d28f238
Rename TrustedOrigins to StartLocationRegistry and expose registration
Sep 23, 2026
1cd1b01
Make OriginTrustPolicy an abstract class that defaults to registered …
Sep 23, 2026
a452f87
Accept a null location in the trust helpers
Sep 23, 2026
8487498
Extract the origin-gated message channel into JavascriptChannel
Sep 23, 2026
8595d31
Pass VisitOptions to the internal visit proposal callbacks
Sep 23, 2026
5256518
Document that in-app routing trusts every navigator's origin
Sep 23, 2026
ae0a18f
Keep VisitError.description() compiling as a deprecated extension
Sep 23, 2026
dbb3c3f
Nest the web message listener call inside its feature check
Sep 23, 2026
225a950
Give the Turbo dispatcher a Unit body and correct the registration KDoc
Sep 23, 2026
61a4ad0
Keep the start-location registry to the default policy
Sep 23, 2026
d1ff4bd
Trim the trust-gating comments to the non-obvious reasons
Sep 23, 2026
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
6 changes: 6 additions & 0 deletions core/src/main/assets/js/bridge_components.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
(() => {
// BridgeComponentsChannel is injected by the native side via WebViewCompat.addWebMessageListener().
const BridgeComponentsNative = new Proxy({}, {
get: (_, name) => (...args) =>
window.BridgeComponentsChannel.postMessage(JSON.stringify({ name, args }))
})

// This represents the adapter that is installed on the webBridge
// All adapters implement the same interface so the web doesn't need to
// know anything specific about the client platform
Expand Down
6 changes: 6 additions & 0 deletions core/src/main/assets/js/turbo.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
(() => {
const TURBO_LOAD_TIMEOUT = 4000

// TurboSessionChannel is injected by the native side via WebViewCompat.addWebMessageListener().
const TurboSession = new Proxy({}, {
get: (_, name) => (...args) =>
window.TurboSessionChannel.postMessage(JSON.stringify({ name, args }))
})

// Bridge between Turbo JS and native code. Built for Turbo 7
// with backwards compatibility for Turbolinks 5
class TurboNative {
Expand Down
42 changes: 25 additions & 17 deletions core/src/main/kotlin/dev/hotwire/core/bridge/Bridge.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
package dev.hotwire.core.bridge

import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.annotation.VisibleForTesting
import dev.hotwire.core.logging.logDebug
import dev.hotwire.core.logging.logVerbose
import dev.hotwire.core.logging.logWarning
import dev.hotwire.core.security.JavascriptChannel
import dev.hotwire.core.security.JavascriptMessage
import dev.hotwire.core.security.stringAt
import kotlinx.serialization.json.JsonElement
import java.lang.ref.WeakReference

// These need to match whatever is set in bridge_components.js
private const val bridgeGlobal = "window.nativeBridge"
private const val bridgeJavascriptInterface = "BridgeComponentsNative"
private const val bridgeChannelName = "BridgeComponentsChannel"

@Suppress("unused")
class Bridge internal constructor(webView: WebView) {
Expand All @@ -20,14 +23,14 @@ class Bridge internal constructor(webView: WebView) {
internal val webView: WebView? get() = webViewRef.get()
internal var repository = Repository()
internal var delegate: BridgeDelegate<*>? = null
internal val channel = JavascriptChannel(bridgeChannelName, ::dispatchBridgeMessage)

init {
// Use a weak reference in case the WebView is no longer being
// used by the app, such as when the render process is gone.
webViewRef = WeakReference(webView)

// The JavascriptInterface must be added before the page is loaded
webView.addJavascriptInterface(this, bridgeJavascriptInterface)
channel.install(webView)
}

internal fun register(component: String) {
Expand Down Expand Up @@ -69,26 +72,31 @@ class Bridge internal constructor(webView: WebView) {
return componentsAreRegistered
}

@JavascriptInterface
fun bridgeDidInitialize() {
logDebug("bridgeDidInitialize", "success")
runOnUiThread {
delegate?.bridgeDidInitialize()
private fun dispatchBridgeMessage(message: JavascriptMessage) {
when (message.name) {
"bridgeDidInitialize" -> bridgeDidInitialize()
"bridgeDidUpdateSupportedComponents" -> bridgeDidUpdateSupportedComponents()
"bridgeDidReceiveMessage" -> bridgeDidReceiveMessage(message.args.stringAt(0))
else -> logWarning(
"javascriptMessageUnknown",
listOf("channel" to bridgeChannelName, "name" to message.name)
)
}
}

@JavascriptInterface
fun bridgeDidUpdateSupportedComponents() {
private fun bridgeDidInitialize() {
logDebug("bridgeDidInitialize", "success")
delegate?.bridgeDidInitialize()
}

private fun bridgeDidUpdateSupportedComponents() {
logDebug("bridgeDidUpdateSupportedComponents", "success")
componentsAreRegistered = true
}

@JavascriptInterface
fun bridgeDidReceiveMessage(message: String?) {
runOnUiThread {
InternalMessage.fromJson(message)?.let {
delegate?.bridgeDidReceiveMessage(it.toMessage())
}
private fun bridgeDidReceiveMessage(message: String) {
InternalMessage.fromJson(message)?.let {
delegate?.bridgeDidReceiveMessage(it.toMessage())
}
}

Expand Down
29 changes: 26 additions & 3 deletions core/src/main/kotlin/dev/hotwire/core/bridge/BridgeDelegate.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import dev.hotwire.core.logging.logDebug
import dev.hotwire.core.logging.logWarning
import dev.hotwire.core.security.isTrustedForNativeAccess

@Suppress("unused")
class BridgeDelegate<D : BridgeDestination>(
Expand All @@ -14,15 +15,19 @@ class BridgeDelegate<D : BridgeDestination>(
) : DefaultLifecycleObserver {
internal var bridge: Bridge? = null
private var destinationIsActive: Boolean = false
// Trust checks use this, not resolvedLocation: the destination's intended
// location says nothing about what is loaded.
private val currentLocation: String?
get() = bridge?.webView?.url
private val resolvedLocation: String
get() = bridge?.webView?.url ?: location
get() = currentLocation ?: location

val initializedComponents = hashMapOf<String, BridgeComponent<D>>()
val activeComponents: List<BridgeComponent<D>>
get() = initializedComponents.map { it.value }.takeIf { destinationIsActive }.orEmpty()

fun onColdBootPageCompleted() {
bridge?.load()
loadBridge()
}

fun onColdBootPageStarted() {
Expand All @@ -36,7 +41,7 @@ class BridgeDelegate<D : BridgeDestination>(

if (bridge != null) {
if (shouldReloadBridge()) {
bridge?.load()
loadBridge()
}
} else {
logWarning("bridgeNotInitializedForWebView", resolvedLocation)
Expand All @@ -49,6 +54,11 @@ class BridgeDelegate<D : BridgeDestination>(
}

fun replyWith(message: Message): Boolean {
if (!isTrustedForNativeAccess(currentLocation)) {
logBlockedForUntrustedOrigin("bridgeReplyBlockedForUntrustedOrigin")
return false
}

bridge?.replyWith(message) ?: run {
logWarning("bridgeMessageFailedToReply", "bridge is not available")
return false
Expand All @@ -72,10 +82,23 @@ class BridgeDelegate<D : BridgeDestination>(
}
}

private fun loadBridge() {
if (!isTrustedForNativeAccess(currentLocation)) {
logBlockedForUntrustedOrigin("bridgeLoadBlockedForUntrustedOrigin")
return
}

bridge?.load()
}

private fun shouldReloadBridge(): Boolean {
return destination.bridgeWebViewIsReady() && bridge?.isReady() == false
}

private fun logBlockedForUntrustedOrigin(event: String) {
logWarning(event, listOf("location" to currentLocation.orEmpty()))
}

// Lifecycle events

override fun onStart(owner: LifecycleOwner) {
Expand Down
16 changes: 0 additions & 16 deletions core/src/main/kotlin/dev/hotwire/core/bridge/Helpers.kt

This file was deleted.

32 changes: 32 additions & 0 deletions core/src/main/kotlin/dev/hotwire/core/config/HotwireConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ package dev.hotwire.core.config

import android.content.Context
import android.webkit.WebView
import androidx.annotation.RestrictTo
import dev.hotwire.core.bridge.BridgeComponent
import dev.hotwire.core.bridge.BridgeComponentFactory
import dev.hotwire.core.bridge.BridgeComponentJsonConverter
import dev.hotwire.core.logging.DefaultHotwireLogger
import dev.hotwire.core.logging.HotwireLogger
import dev.hotwire.core.security.DefaultOriginTrustPolicy
import dev.hotwire.core.security.Origin
import dev.hotwire.core.security.OriginTrustPolicy
import dev.hotwire.core.security.StartLocationRegistry
import dev.hotwire.core.turbo.config.PathConfiguration
import dev.hotwire.core.turbo.offline.OfflineRequestHandler
import dev.hotwire.core.turbo.webview.HotwireWebView
Expand Down Expand Up @@ -40,6 +45,33 @@ class HotwireConfig internal constructor() {
*/
var logger: HotwireLogger = DefaultHotwireLogger

internal val startLocationRegistry = StartLocationRegistry()

/**
* A live view of the origins of the start locations that `NavigatorHost`
* registers.
*/
val registeredOrigins: Set<Origin>
get() = startLocationRegistry.origins

// Not public: under the default policy, adding an origin grants it
// native access.
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
fun registerStartLocation(startLocation: String) {
startLocationRegistry.register(startLocation)
}

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
fun unregisterStartLocation(startLocation: String) {
startLocationRegistry.unregister(startLocation)
}

/**
* The default, [DefaultOriginTrustPolicy], trusts only [registeredOrigins].
* Set your own if your app trusts more or does not use `NavigatorHost`.
*/
var originTrustPolicy: OriginTrustPolicy = DefaultOriginTrustPolicy

/**
* Enables/disables debugging of web contents loaded into WebViews.
* Disabled by default.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import dev.hotwire.core.R
import dev.hotwire.core.files.util.HOTWIRE_REQUEST_CODE_FILES
import dev.hotwire.core.files.util.HotwireFileProvider
import dev.hotwire.core.logging.logError
import dev.hotwire.core.logging.logWarning
import dev.hotwire.core.security.isTrustedForNativeAccess
import dev.hotwire.core.turbo.session.Session
import dev.hotwire.core.turbo.util.dispatcherProvider
import kotlinx.coroutines.CoroutineScope
Expand All @@ -31,6 +33,16 @@ class FileChooserDelegate(val session: Session) : CoroutineScope {
filePathCallback: ValueCallback<Array<Uri>>,
params: FileChooserParams
): Boolean {
// FileChooserParams has no origin, so gate on the page's URL.
val pageLocation = session.webView.url
if (!isTrustedForNativeAccess(pageLocation)) {
logWarning("fileChooserBlockedForUntrustedOrigin", listOf("location" to pageLocation.orEmpty()))
filePathCallback.onReceiveValue(null)
return true
}

// Answer a request still held from before; the WebView needs a verdict.
handleCancellation()
uploadCallback = filePathCallback

return openChooser(params).also { success ->
Expand Down Expand Up @@ -84,8 +96,16 @@ class FileChooserDelegate(val session: Session) : CoroutineScope {
}
}

private fun sendResult(results: Array<Uri>?) {
uploadCallback?.onReceiveValue(results)
internal fun sendResult(results: Array<Uri>?) {
// The WebView may have navigated while the picker was open.
val pageLocation = session.webView.url

if (results != null && !isTrustedForNativeAccess(pageLocation)) {
logWarning("fileChooserResultBlockedForUntrustedOrigin", listOf("location" to pageLocation.orEmpty()))
uploadCallback?.onReceiveValue(null)
} else {
uploadCallback?.onReceiveValue(results)
}
uploadCallback = null
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import androidx.core.content.ContextCompat
import androidx.core.content.PermissionChecker
import dev.hotwire.core.files.util.HOTWIRE_REQUEST_CODE_GEOLOCATION_PERMISSION
import dev.hotwire.core.logging.logError
import dev.hotwire.core.logging.logWarning
import dev.hotwire.core.security.isTrustedForNativeAccess
import dev.hotwire.core.turbo.session.Session

class GeolocationPermissionDelegate(private val session: Session) {
Expand All @@ -23,10 +25,16 @@ class GeolocationPermissionDelegate(private val session: Session) {
origin: String?,
callback: GeolocationPermissions.Callback?
) {
// Answer a request still held from before; the WebView needs a verdict.
permissionDenied()

requestOrigin = origin
requestCallback = callback

if (requestOrigin == null || requestCallback == null || permissionToRequest == null) {
if (!isTrustedForNativeAccess(origin)) {
logWarning("geolocationPermissionBlockedForUntrustedOrigin", listOf("origin" to origin.orEmpty()))
permissionDenied()
} else if (callback == null || permissionToRequest == null) {
permissionDenied()
} else if (hasLocationPermission(context)) {
permissionGranted()
Expand All @@ -43,6 +51,16 @@ class GeolocationPermissionDelegate(private val session: Session) {
}
}

/**
* Called from [android.webkit.WebChromeClient.onGeolocationPermissionsHidePrompt].
* The WebView no longer wants an answer, so the held request is dropped
* unanswered.
*/
fun onHidePrompt() {
requestOrigin = null
requestCallback = null
}

private fun startPermissionRequest() {
val destination = session.currentVisit?.callback?.visitDestination() ?: return
val resultLauncher = destination.activityPermissionResultLauncher(
Expand All @@ -64,7 +82,9 @@ class GeolocationPermissionDelegate(private val session: Session) {
}

private fun permissionGranted() {
requestCallback?.invoke(requestOrigin, true, true)
// The policy's answer may have changed while the dialog was up.
val allow = isTrustedForNativeAccess(requestOrigin)
requestCallback?.invoke(requestOrigin, allow, allow)
requestOrigin = null
requestCallback = null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import androidx.core.content.PermissionChecker
import dev.hotwire.core.files.util.HOTWIRE_REQUEST_CODE_WEBVIEW_PERMISSION
import dev.hotwire.core.logging.logError
import dev.hotwire.core.logging.logWarning
import dev.hotwire.core.security.isTrustedForNativeAccess
import dev.hotwire.core.turbo.session.Session

/**
Expand Down Expand Up @@ -39,6 +40,13 @@ class WebViewPermissionDelegate(private val session: Session) {
private var pendingRequest: PermissionRequest? = null

fun onRequest(request: PermissionRequest) {
val origin = request.origin?.toString()
if (!isTrustedForNativeAccess(origin)) {
logWarning("webViewPermissionBlockedForUntrustedOrigin", listOf("origin" to origin.orEmpty()))
request.deny()
return
}

val requestedResources = request.resources?.toList().orEmpty()
val supportedResources = requestedResources.filter { it in SUPPORTED_RESOURCES }

Expand Down Expand Up @@ -100,7 +108,8 @@ class WebViewPermissionDelegate(private val session: Session) {
grantResults[permission] == true || isGranted(permission)
}

if (allGranted) {
// The policy's answer may have changed while the dialog was up.
if (allGranted && isTrustedForNativeAccess(request.origin?.toString())) {
request.grant(resources.toTypedArray())
} else {
request.deny()
Expand Down
Loading
Loading