Skip to content

Scope the AndroidPOST web view bridge to the app's own origin - #655

Merged
simonhamp merged 1 commit into
mainfrom
harden/android-webmessage-listener
Nov 5, 2026
Merged

simonhamp merged 1 commit into
mainfrom
harden/android-webmessage-listener

Conversation

@simonhamp

Copy link
Copy Markdown
Member

Scopes the Android AndroidPOST web view bridge to the app's own origin, from acd421's retest.

addJavascriptInterface(..., "AndroidPOST") exposed the request-body-capture and CSRF-token methods to every frame in the web view, including a cross-origin iframe the app might load. This moves to WebViewCompat.addWebMessageListener scoped to the app's own origin, so only first-party page content can reach the bridge, with a feature-check fallback for older WebViews. The request-interception behaviour (streaming body capture, CSRF token storage) is preserved.

See the branch's own commit notes for the fallback choice. Native change: reaches an installed app after native:install --force + rebuild.

🤖 Generated with Claude Code

addJavascriptInterface exposes the injected object to every frame in the web
view, so a cross-origin iframe the app happened to load could call the
request-body and CSRF methods on AndroidPOST and feed or poison what reaches
PHP. Move AndroidPOST to androidx WebViewCompat.addWebMessageListener scoped to
the origins the app actually serves pages from (http://127.0.0.1 and
http://localhost), so only first-party page content can reach it.

The page now talks to the bridge with AndroidPOST.postMessage(json); the
listener unpacks a {method, args} envelope and calls the existing JSBridge
methods, which are unchanged. The request-body shim and the CSRF injector both
go through one helper that works with either transport. WebViews too old for
WebMessageListener keep addJavascriptInterface with a logged warning, because
refusing the bridge there would break every form and non-GET request; the
@JavascriptInterface methods stay for that path.

storePostBody/consumePostBody already wait briefly for the body to arrive, so
moving from a synchronous interface call to an async postMessage doesn't drop
bodies on the normal fetch, XHR and form paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@simonhamp
simonhamp merged commit 944422d into main Nov 5, 2026
5 checks passed
@simonhamp
simonhamp deleted the harden/android-webmessage-listener branch November 5, 2026 15:38
simonhamp added a commit that referenced this pull request Nov 5, 2026
* Authenticate native device events so a web view page can't forge them

/_native/api/events has no middleware, so a page can POST to it itself. The
3.3.11 marker gate already stops it from constructing an arbitrary class, but a
page could still fire a real device event — a forged PaymentCompleted, a
Biometric\Completed(success: true), a scanner or camera result — and run the
app's listeners as if the device sent it.

The native shell now signs every event it posts there with the app key (held in
the Keychain/Keystore, never handed to the page) over a per-event nonce, a
timestamp and the exact request body. PHP recomputes the HMAC with the same key
and rejects anything stale, replayed or unsigned, then fires the event. The
shell builds the body by concatenation and posts it verbatim, so the bytes it
signs are exactly what PHP reads back as request()->getContent().

Enforcement is gated on NATIVEPHP_EVENTS_SIGNED, a native setenv on the embedded
PHP set only at on-device shell boot (iOS NativePHPApp.setupEnvironment, Android
LaravelEnvironment). It never reaches a shared PHP bootstrap or a dev-machine
PHP, so those stay unsigned and enforcement stays off there. An app that updates
only the composer package without rebuilding keeps working unsigned until it
reinstalls the native project.

The PHP verifier (Support/NativeEventSignature.php) is byte-identical to the 4.x
line, and the two native signers match its purpose string, message layout and
header names. Backport of #653. Reported by acd421 in a retest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Harden the Android web view: gate remote debugging and origin-scope AndroidPOST

Two residuals acd421 flagged in the retest, both in WebViewManager.

setWebContentsDebuggingEnabled(true) was unconditional, so release builds
shipped with chrome://inspect debugging on — anyone with device or ADB access
could inspect and drive the web view in production. Gate it on the app's
debuggable flag so it's on for debug builds and off for release.

addJavascriptInterface exposes the AndroidPOST bridge to every frame in the web
view, including any cross-origin iframe the app loads, so a foreign frame could
poison the stored CSRF token or feed body data to PHP through the
request-interception plumbing. Move AndroidPOST to WebViewCompat
.addWebMessageListener scoped to the origins the app actually serves pages from
(http://127.0.0.1 and http://localhost), so only first-party content reaches it.
The page now talks to the bridge via AndroidPOST.postMessage(JSON); a shared
helper sends through either transport, and the request-body shim's existing
50ms wait in PHPBridge.consumePostData absorbs postMessage's async delivery on
the fetch/XHR/form paths. WebViews too old for WEB_MESSAGE_LISTENER keep
addJavascriptInterface with a logged warning, since refusing the bridge there
would break every form and non-GET request.

Backport of #654 and #655.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Block OTA bridge calls from the web view

Ota.Download takes a caller-supplied URL and stages a zip that core applies on
the next boot with no signature check, so a web view page (an XSS, or a bundled
third-party script) calling it through /_native/api/call could stage an attacker
bundle and get it run as the app's code next launch. OTA is app-logic, not a
page action — app PHP drives it in-process via nativephp_call, which does not
pass through this controller — so refuse the Ota. prefix from the web view with
a 403.

3.x has no AsyncTask internal methods, so the refused-prefix list is just
['Ota.']. The gate ships with the composer package, independent of whether the
app has rebuilt its native project. Backport of #656; found during the
post-retest plugin sweep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant