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.
added 4 commits
September 14, 2026 11:19
The extension function fixed the R8 VerifyError but cost Kotlin callers an import and Java callers the method. An abstract property on the sealed interface keeps member access with no default method on VisitError. HttpError's getters live on ClientError, ServerError, and UnknownError — the direct supertypes of its concrete cases — because R8 rewrites a default-method accessor into an invokespecial on the declaring interface, which the verifier rejects when that interface is only an indirect supertype.
The gate protected only an app callback that supplies credentials without looking at the challenged host, and it had to guess the scheme and port the WebView never reports. The app is the party that holds the credentials and already receives the host, so the check belongs there. The default callback still cancels every challenge; its documentation now states that the host can be any server the page loads from.
The verifier took a raw String that was sometimes a full URL, sometimes a bare origin, and once a synthesized "https://host", so every custom implementation had to parse and fail closed on its own. The library now parses locations itself, rejects non-http(s) input before the policy runs, and hands the policy an Origin value whose equality is same-origin equality. The vocabulary settles on "origin" throughout: the type was named for hosts, the errors and logs for origins, the registry for locations. DefaultOriginTrustPolicy is public so an app can delegate to it instead of copying it. The three RestrictTo registration functions on HotwireConfig collapse into one trustedOrigins entry point, and registeredStartLocations becomes registeredOrigins: Set<Origin>, which no longer leaks the okhttp string form with a trailing slash.
Error views could not show which origin was refused.
added 2 commits
September 23, 2026 10:09
A public constructor let apps build origins that never equal a parsed one
("HTTPS", "Partner.example.com", port -1), so a custom policy denied
silently. Parsing through OkHttp is the only way in now, which keeps every
origin normalized. Origin.parse() throws for policy constants;
String.toOriginOrNull() duplicated Origin.parseOrNull() and is gone.
The set holds registered start locations, not trusted origins; the policy decides trust, and a custom one can trust origins outside it. The config also carried two names for the one set. Apps that drive a Session without navigation-fragments had no way to register an origin, so the default policy failed every cold boot. HotwireConfig.registerStartLocation() and unregisterStartLocation() are now public, NavigatorHost uses them too, and the registry is internal. registeredOrigins is a live read-only view instead of a copy per read, so a policy can test membership on every message without allocating.
added 10 commits
September 23, 2026 10:10
…origins As an interface, any gate added later would break every app's policy. Open methods with the registered-origins default let a new gate arrive with a safe answer, and a custom policy overrides one method and calls super instead of delegating to DefaultOriginTrustPolicy. The KDoc example compared only the host, which trusts a partner's http:// pages and every port; it now compares a full Origin. Native access now also requires navigation trust. The KDoc asked for that but nothing enforced it.
Six call sites repeated `x == null || !isTrustedForNativeAccess(x)`. The helpers now fail closed on null themselves, which removes the private isTrustedOrigin and originIsTrustedForBridge wrappers and the duplicate null branch in the geolocation request. The block logs in the file chooser and permission delegates now use the attribute form the bridge and session already use. BridgeDelegate logs the page's actual location, not the destination's intended one, which was not the reason for the block when no document was loaded.
Bridge and Session each carried a copy of the security gate: feature check, "*" rules, main-frame and origin checks, decode, and failure handling. One internal class now owns it, so a future channel cannot skip a step by accident, and the test seam for Robolectric's missing WebMessageListener lives in one place instead of on Session. JavascriptMessage moves next to it in the security package; the bridge no longer imports turbo.util, which imported the bridge back. The argument accessors are now stringAt/booleanAt/intAt, since string(0) read like a conversion. Block, malformed, and unknown-message logs share one set of event names with a channel attribute.
With the callbacks internal, the JSON string parameter only served the old JavascriptInterface. The web view client, download listener, and chrome client serialized VisitOptions so the callback could parse it again; the message dispatcher now parses once and passes the object. The ?.let blocks and the else branch in turboIsReady were left over from the removed whenTrustedOrigin wrapper; the early returns are back.
The handler used to compare against its own navigator's start location. It now asks the policy, whose default trusts every registered start location, so a navigator opens another navigator's origin in-app. Say so in the KDoc and pin it with a test.
The function became a property in this release line. A deprecated extension with ReplaceWith keeps existing call sites compiling for a release; as a static method it avoids the R8 default-method problem that moved description off the interface.
Lint's RequiresFeature check does not follow the early return, so it flagged addWebMessageListener as unguarded.
The expression body inferred Unit? from the ?.let branches. The KDoc named DefaultOriginTrustPolicy, but the base policy's defaults read the registered origins too.
Apps with their own source of trust (a selected server environment, asset hosts that may navigate but never reach native code) answer both questions from it and never read the registry. The open methods let such a policy inherit registry trust silently for any method it did not override, and the registry can disagree with the app's source after a runtime environment switch. Both current methods are abstract again, so every policy answers them explicitly; DefaultOriginTrustPolicy answers from the registry. A future gate becomes an open method that defaults to isTrustedForNativeAccess, the policy's own strictest answer, so adding one still breaks no app. Start-location registration is library-only again. No app that sets its own policy needs it, and under the default policy it was a way to grant a host native access by mistake. Apps without NavigatorHost set a policy; the KDoc says so.
Drop comments the code already says (JavascriptMessage's shape, the http(s)-only note the Origin type implies, the Robolectric note repeated in SessionTest) and the default-policy sentence repeated across three KDocs. Shorten the rest to the reason alone. The "*" listener comment now gives the actual reason: the policy can change after install.
mbarta
added this pull request to stack #219
September 23, 2026 13:55
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 origin verification to the library so that security-sensitive actions are only performed with origins the app trusts.
What's new
OriginTrustPolicyabstract class with two decisions: whether an origin may be routed through in-app navigation, and whether pages at an origin may reach native code. Both are abstract, so a custom policy answers each explicitly; a gate added later arrives as an open method that defaults toisTrustedForNativeAccess, so it won't break existing policies. Native access also requires navigation trust. Both take a typedOrigin(scheme, host, effective port) that is only created by parsing (Origin.parse,Origin.parseOrNull), so its parts are always normalized. The library parses every location itself and never consults the policy for anything that isn't an http(s) URL, so implementations only see well-formed origins.DefaultOriginTrustPolicy, a public object that trusts an origin only when it matches a navigator host's start location. Single-origin apps need no configuration. Apps that trust more set their own policy, and can callDefaultOriginTrustPolicyfrom it to extend the default. Apps that don't useNavigatorHostregister no start locations, so they must set a policy.Hotwire.config.originTrustPolicyseam, following the same pattern asHotwire.config.logger.Hotwire.config.registeredOrigins: Set<Origin>is a live, read-only view of the set.Where it's enforced
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.UntrustedOrigin(location)through the standard error path — the app shows its regular error view (with retry), andcreateErrorViewimplementations can match the new error and read the refused location.Behavior notes
WebMessageListener, available on WebView 88+ (2021). On older WebViews the visit fails withLoadError.WebViewNotSupportedthrough the standard error path instead of hanging.HotwireWebFragmentCallback.onReceivedHttpAuthRequestunchanged, as before. The default cancels them. The callback's documentation now notes thathostcan be any server the page loads resources from and receives the credentials passed toproceed, so an app that supplies credentials must check it.Source-visible changes for the release notes
LoadErrorgainsUntrustedOrigin(location)andWebViewNotSupported; exhaustivewhenblocks need new branches.VisitError.descriptionis an abstract property on the sealed interface instead of a member function with a default body.description()keeps compiling for now as a deprecated extension withReplaceWith("description"). The property also fixes an R8VerifyErrorin minified release builds: the getters forHttpErrorlive onClientError,ServerError, andUnknownError, the direct supertypes of its concrete cases.Session's former@JavascriptInterfacemethods — long documented as never-call-directly — are now internal.