Add configurable host verification - #215
Open
mbarta wants to merge 19 commits into
Open
Conversation
added 19 commits
August 27, 2026 13:40
A public interface for the two host trust decisions the library makes: routing a location through in-app navigation, and letting a page talk to native code (JS injection, component registration, bridge messages). The default implementation trusts a location only when its origin (scheme, host, port) equals the origin of the navigator's start location - the app-authored trust anchor. Unparseable and non-http(s) locations are never trusted. Apps that trust multiple hosts provide their own implementation via Hotwire.config.hostVerifier, following the same pattern as Hotwire.config.logger.
Consult the configured HostVerifier at every point where the library previously trusted a location implicitly: - AppNavigationRouteDecisionHandler matches by verified origin instead of host-string equality, which ignored scheme and port. - Session blocks turbo.js installation into a page whose location fails bridge verification (e.g. a cold-boot redirect to a foreign origin). - BridgeDelegate blocks loading the bridge user script, registering components, and dispatching received messages when the WebView's actual URL fails bridge verification. The page-supplied metadata url remains only an exact-location equality check; the trust decision uses the WebView's own URL. The navigator's start location flows into Session and BridgeDelegate as the verifier's trust anchor. Blocked actions are logged and dropped; surfacing them through the error path is planned separately.
…d origins Every remaining page-to-native channel now consults the HostVerifier: - Session's TurboSession JavascriptInterface methods run only when the WebView's current page - read on the main thread - passes bridge verification. Any page loaded in the WebView can call these methods, and visitProposedToLocation alone lets a page drive native navigation. - BridgeDelegate.replyWith refuses to deliver a reply into a page on an untrusted origin (e.g. a stale component reply after navigation). - The geolocation and media-capture permission delegates deny requests whose requesting origin fails bridge verification, before any Android permission handling. Blocked calls are logged and dropped.
WebViewClient.onReceivedHttpAuthRequest forwards the challenge to the app's callback, where apps commonly auto-supply stored basic-auth credentials. A subresource or redirect to an attacker host that answers 401 could make the app hand credentials to that host. Verify the WebView's current page origin before forwarding; cancel the handler otherwise (matching the safe library default). The library-level gate protects apps that override the callback without checking the origin themselves.
Every blocked action now logs at warning level with a uniform ...BlockedForUntrustedOrigin event name and carries both the rejected location and the startLocation anchor it failed against, so the log explains the decision rather than just naming the rejected url. Also gives the geolocation untrusted-origin block its own log instead of folding it silently into the shared permission-denied path, and adds a logWarning(event, attributes) overload mirroring logDebug.
Instead of threading each navigator's start location through Session and BridgeDelegate and passing it to every verify call, navigator hosts register their start location in a central TrustedLocations registry on HotwireConfig as they initialize. DefaultHostVerifier checks membership against it. This restores the original public Session and BridgeDelegate constructors (no breaking change) and simplifies the HostVerifier interface to isTrustedForNavigation(location) / isTrustedForBridge( location). Trust is now app-wide across navigators rather than strictly per-navigator; every registered start location is an app-declared origin, so a page trusted by one navigator is trusted by all. Apps needing more than their start locations provide a custom HostVerifier and can read the registered locations from config.trustedLocations. Registration is exposed via @RestrictTo(LIBRARY_GROUP) so navigator hosts (a separate module) can call it while it stays out of the app surface.
A cold-boot page that fails bridge verification previously stayed on screen as a plain page with no Turbo adapter, indistinguishable from a working visit. Reset the session and report the standard error path instead, so the app shows its error view with retry, and apps can match LoadError.UntrustedOrigin in createErrorView for a custom message. Only the installation decision surfaces an error: the other gates drop individual events on pages that are otherwise fine, and failing the visit for those would be disruptive and give a probing page feedback.
Each fact keeps one home: default behavior on DefaultHostVerifier, when to customize on the hostVerifier config property, the contract on the interface methods. Cross-site restatements removed.
…nels JavascriptInterface objects are injected into every frame and carry no caller identity, so the origin gates could only check the top-level WebView URL. WebViewCompat.addWebMessageListener stamps each message with the posting frame's browser-reported origin and main-frame flag, so every Turbo session and bridge message is now gated on values the page cannot forge, before it is even decoded. The per-method whenTrustedOrigin wrappers and the delegate-level incoming-message gates collapse into the two channel gates. The native-to-JS gates (bridge load, replyWith, installBridge) stay. WebViews without WebMessageListener support (below 88) log an error and never install the channels.
The challenge names the server that receives any credentials the app's callback supplies, so page trust is the wrong question: a trusted page can embed a subresource from a hostile server, and during a cold boot the challenge arrives before the page URL commits. Verify the challenged host as an https origin instead — this blocks credential callbacks for foreign servers and unbreaks main-frame auth on first load.
The file chooser is a native capability like geolocation and media capture, so it gets the same rule: an untrusted page can't open it. FileChooserParams carries no origin, so the page's location is the gate's authority. The permission delegates check trust when a prompt opens, but the native permission dialog resolves later — re-verify the origin at grant time in case the verifier's answer changed in the interim. The geolocation delegate also picks up two behaviors its media-capture sibling already had: a second request answers the held one instead of orphaning it, and a hidden prompt (onGeolocationPermissionsHidePrompt) drops the held request.
Host equality accepts a scheme downgrade or an alternate port, and the deep-link start location becomes the graph's start destination without passing any later gate — the cold-boot request leaves the device first, cookies included. Compare scheme, host, and effective port instead, with the comparison lifted out of DefaultHostVerifier into a shared helper. An unparseable deep link now also falls back to the configured start location.
The origin gate fell back to the destination's constructor location when the WebView had no committed document, answering "is the page we meant to load trusted?" instead of "is the page actually there trusted?". Bridge loads and replies now require an actual page URL; the constructor-location fallback survives only for message routing and log labels.
R8 rewrites the Kotlin $jd accessor for the default interface method into an invokespecial that targets VisitError from HttpError's nested subtypes — an indirect superinterface, which the JVM verifier rejects (VerifyError on class load, surfaced through HttpError.from's use of kotlin-reflect; testReleaseUnitTest was red). An extension function compiles to a plain static method, so the fragile accessor pattern no longer exists. Kotlin call sites are unchanged apart from the import.
The picker is open while the WebView keeps running, so the page that receives the chosen files may not be the page that asked — re-verify before delivering, and hand back null when trust is gone. A new chooser request now also answers any previously-held callback instead of orphaning it (the WebView refuses to reopen the chooser for an unanswered callback).
Without WebMessageListener support the channels never install, so the injected Turbo adapter throws on its first call and the visit hangs on a spinner with one log line as the only witness. Gate the cold boot on the channel actually existing and fail the visit through the standard error path instead.
A registered start location never expired, so an app that rotates its start location kept every past origin trusted for the process lifetime. NavigatorHost now withdraws its registration on destroy; registrations are counted so a start location shared by two hosts survives until the last one is gone. The registry stores parsed origins instead of raw strings: a location that is not an http(s) URL is refused loudly at registration instead of sitting in the set as a silently unmatchable entry, and verification becomes an exact origin lookup. The global clear is now test-only.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds host verification to the library so that security-sensitive actions are only performed with locations the app trusts.
What's new
HostVerifierinterface with two decisions: whether a location may be routed through in-app navigation, and whether a page may interact with native code.Hotwire.config.hostVerifierseam, following the same pattern asHotwire.config.logger, for apps that trust multiple hosts or already have their own verification facility.Hotwire.config.registeredStartLocations.Where it's enforced
The library consults the verifier consistently wherever a location's trust matters:
WebViewCompat.addWebMessageListenerchannels. Every message is checked against the browser-reported origin of the frame that posted it — main frame only — before it is decoded. Page-visible objects grant nothing by themselves.Blocked actions fail closed and are logged at warning level.
When a loaded page fails verification, the visit surfaces
LoadError.UntrustedOriginthrough the standard error path — the app shows its regular error view (with retry), andcreateErrorViewimplementations can match the new error for a custom message.Behavior notes
WebMessageListener, available on WebView 88+ (2021). On older WebViews the visit fails withLoadError.WebViewNotSupportedthrough the standard error path instead of hanging.LoadErrorgains theUntrustedOriginandWebViewNotSupportedcases (exhaustivewhenblocks need new branches),VisitError.description()is now an extension function (Kotlin call sites just add an import; this also fixes an R8VerifyErrorin minified release builds), andSession's former@JavascriptInterfacemethods — long documented as never-call-directly — are now internal.