Skip to content

Add a configurable origin trust policy - #215

Open
mbarta wants to merge 35 commits into
mainfrom
host-verification
Open

mbarta wants to merge 35 commits into
mainfrom
host-verification

Conversation

@mbarta

@mbarta mbarta commented Aug 31, 2026 •

Copy link
Copy Markdown
Collaborator

Adds origin verification to the library so that security-sensitive actions are only performed with origins the app trusts.

What's new

  • A public OriginTrustPolicy abstract 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 to isTrustedForNativeAccess, so it won't break existing policies. Native access also requires navigation trust. Both take a typed Origin (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 call DefaultOriginTrustPolicy from it to extend the default. Apps that don't use NavigatorHost register no start locations, so they must set a policy.
  • A Hotwire.config.originTrustPolicy seam, following the same pattern as Hotwire.config.logger.
  • Navigator hosts register their start location as trusted for as long as they live: the registration is withdrawn when the host is destroyed, and a start location shared by several hosts stays trusted until the last one is gone. Registration is library-only: under the default policy, adding an origin grants it native access. Hotwire.config.registeredOrigins: Set<Origin> is a live, read-only view of the set.

Where it's enforced

  • Route decisions for in-app navigation, and the start location a launching Intent's deep link provides — compared by full origin before the first request is issued, falling back to the configured start location.
  • The library's own JavaScript: injecting it into pages, registering bridge components, and replying to them — all require a trusted current document.
  • Messages from web content: the Turbo session and bridge JavaScript interfaces are replaced with WebViewCompat.addWebMessageListener channels. 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.
  • WebView-initiated callbacks: the file chooser requires a trusted page, and geolocation/media-capture grants re-verify their origin when the asynchronous permission dialog resolves.

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), and createErrorView implementations can match the new error and read the refused location.

Behavior notes

  • The default app-navigation route matching is now origin-based (previously host-only), so scheme and port are part of the decision. It asks the policy instead of comparing against its own navigator's start location, so with the default policy a navigator also opens other navigators' origins in-app.
  • Turbo and bridge messaging now rely on WebMessageListener, available on WebView 88+ (2021). On older WebViews the visit fails with LoadError.WebViewNotSupported through the standard error path instead of hanging.
  • The file chooser re-verifies the page when the picker returns, and a new chooser request answers any previously-held callback instead of orphaning it.
  • HTTP auth challenges are forwarded to HotwireWebFragmentCallback.onReceivedHttpAuthRequest unchanged, as before. The default cancels them. The callback's documentation now notes that host can be any server the page loads resources from and receives the credentials passed to proceed, so an app that supplies credentials must check it.
  • Verification is never based on page-supplied values — only on locations the WebView, the browser engine, or the app itself reports.

Source-visible changes for the release notes

  • LoadError gains UntrustedOrigin(location) and WebViewNotSupported; exhaustive when blocks need new branches.
  • VisitError.description is 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 with ReplaceWith("description"). The property also fixes an R8 VerifyError in minified release builds: the getters for HttpError live on ClientError, ServerError, and UnknownError, the direct supertypes of its concrete cases.
  • Session's former @JavascriptInterface methods — long documented as never-call-directly — are now internal.
  • Public constructors are unchanged.

Milan Barta 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.
Milan Barta 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.
@mbarta mbarta changed the title Add configurable host verification Add a configurable origin trust policy Sep 14, 2026
Milan Barta 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.
Milan Barta 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
mbarta added this pull request to stack #219 September 23, 2026 13:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant