Repository navigation
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.
BrowseFilesDelegate builds its result on the IO dispatcher and hands it straight to sendResult, which now reads webView.url. WebView enforces its thread check there, so picking a file from the system picker crashed the app. Hop to the main thread before the trust check and the callback. The new test runs the result through the browse delegate on a real IO dispatcher and asserts the callback's looper, since a mocked WebView can't see the thread. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The app handler now matches by origin, but the browser-tab and system handlers still matched by host. A link to http://my.app.com or to another port of the app host matched none of the three defaults, so the router cancelled it and the tap did nothing. Before the origin policy it navigated in-app. Both fallback handlers now match whatever the policy doesn't trust for navigation, so the three defaults agree on what is internal. RouterTest runs the default list end to end and asserts that every location either navigates or starts an intent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
With no bridge there is no page URL either, so the trust check failed first and logged "blocked for untrusted origin" for what is really a missing bridge. The "bridge is not available" log could never fire. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A retained grant lets the WebView answer later geolocation requests for that origin without asking the delegate, so a policy change or a host teardown couldn't withdraw it. The delegate grants without a dialog whenever the Android permission exists, so users see no extra prompts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The example trusted a partner for navigation only. Routed in-app as a web destination, that page fails: a warm Turbo visit can't fetch it cross-origin and a cold boot refuses it with UntrustedOrigin. Navigation trust on its own is for locations the path configuration sends to a native fragment, so the example now opens CDN images in a native viewer and says what a web destination needs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The untrusted-origin branch returned before handleCancellation(), so a callback held from an earlier request stayed unanswered while the new one was refused. Answer the held one first, whatever the verdict. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.Navigation trust on its own is for locations the path configuration routes to a native fragment, such as CDN images into a native image viewer. A web destination also needs native access, or its visit fails with
LoadError.UntrustedOrigin. TheOriginTrustPolicyKDoc shows the native-fragment case.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. The channel objects are injected into every frame, including third-party iframes, so an embedded page can see them; they grant nothing by themselves.FileChooserParamscarries no requesting frame, so a file input inside an iframe on a trusted page passes.Blocked actions fail closed and are logged at warning level.
When a cold-boot 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. A non-Turbo navigation after boot, such as a form withdata-turbo="false"posting to another origin, loads without this error; the message gate still keeps that document away from native code.Behavior notes
AppNavigationRouteDecisionHandlernavigates in-app whenisTrustedForNavigationtrusts the origin, so scheme and port are part of the decision (previously host-only).BrowserTabRouteDecisionHandlerandSystemNavigationRouteDecisionHandlertake whatever the policy doesn't trust, so a same-host link onhttp://or another port opens in the browser instead of falling through.NavigatorHostserves is the app's own; this is a deliberate change from matching only the navigator's own host.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. This is a breaking change fordescription()callers.description()survives only as a deprecated top-level extension indev.hotwire.core.turbo.errors, so Kotlin callers in other packages must either switch to the property or addimport dev.hotwire.core.turbo.errors.description, and Java callers lose the method. Keeping a member was tried and rejected: R8 rewrites the Kotlin$jdaccessor for a default interface method into aninvokespecialonVisitErrorfromHttpError's nested cases, an indirect superinterface, which the JVM verifier rejects (VerifyErrorintestRelease, which CI runs). ART accepts the same bytecode. The property also sidesteps that: 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.