Skip to content

Add configurable host verification - #215

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

Add configurable host verification#215
mbarta wants to merge 19 commits into
mainfrom
host-verification

Conversation

@mbarta

@mbarta mbarta commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

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

What's new

  • A public HostVerifier interface with two decisions: whether a location may be routed through in-app navigation, and whether a page may interact with native code.
  • A default implementation that trusts a location only when its origin (scheme, host, port) matches a navigator host's start location. Single-origin apps need no configuration.
  • A Hotwire.config.hostVerifier seam, following the same pattern as Hotwire.config.logger, for apps that trust multiple hosts or already have their own verification facility.
  • Navigator hosts register their start location as trusted for as long as they live: the registration is withdrawn when the host is destroyed, so an origin the app no longer uses does not stay trusted, and a start location shared by several hosts stays trusted until the last one is gone. A read-only snapshot of the registered origins is available to custom implementations via Hotwire.config.registeredStartLocations.

Where it's enforced

The library consults the verifier consistently wherever a location's trust matters:

  • 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: HTTP auth challenges are verified against the challenged host (the server that would receive credentials), 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 through the standard error path — the app shows its regular error view (with retry), and createErrorView implementations can match the new error for a custom message.

Behavior notes

  • The default app-navigation route matching is now origin-based (previously host-only), so scheme and port are part of the decision.
  • 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.
  • Public constructors are unchanged. Three source-visible changes for the release notes: LoadError gains the UntrustedOrigin and WebViewNotSupported cases (exhaustive when blocks need new branches), VisitError.description() is now an extension function (Kotlin call sites just add an import; this also fixes an R8 VerifyError in minified release builds), and Session's former @JavascriptInterface methods — long documented as never-call-directly — are now internal.
  • Verification is never based on page-supplied values — only on locations the WebView, the browser engine, or the app itself reports.

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.
@mbarta mbarta self-assigned this Sep 2, 2026
@mbarta
mbarta requested a review from jayohms September 2, 2026 09:42
@mbarta
mbarta marked this pull request as ready for review September 2, 2026 09:42
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