Skip to content

Authenticate device events posted to /_native/api/events - #653

Merged
simonhamp merged 3 commits into
mainfrom
harden/native-event-auth
Nov 5, 2026
Merged

simonhamp merged 3 commits into
mainfrom
harden/native-event-auth

Conversation

@simonhamp

Copy link
Copy Markdown
Member

Authenticates the device events the native shell posts to /_native/api/events, closing the forgery acd421 flagged (F3).

That route has no middleware and the shell delivers events through it, so a web view page (an XSS, or a bundled third-party script) could POST a forged device event — PaymentCompleted with its own amount, Biometric\Completed(success: true) — and the app's listeners and pending callbacks would fire as if the device sent it. The marker-interface gate added earlier stopped arbitrary-class object injection but not forgery of real events.

The native shell now signs each event with the app key (held in the Keychain/Keystore, never exposed to the page) over a nonce, a timestamp and the exact body. NativeEventSignature verifies the HMAC, rejects a stale or replayed one (one-time nonce, 30s window), and the controller drops anything that fails. Binding the signature to the body stops a script that reads a genuine event's signature from reusing it with a different payload; the one-time nonce stops replay.

Enforcement is gated on NATIVEPHP_EVENTS_SIGNED, which the shell sets at boot once it signs — so updating only the Composer package keeps working unsigned until the native project is reinstalled. The flag is process environment, out of a page's reach. The PHP gate ships in the package; the native signing needs native:install --force + rebuild. Tests cover signed/unsigned/tampered-body/stale/replay/bad-key.

🤖 Generated with Claude Code

simonhamp and others added 3 commits November 4, 2026 11:14
/_native/api/events has no middleware, and the bridge delivers a device event
by injecting a fetch() into the page, so the endpoint can't tell a native event
from one a page fabricates. The marker-interface gate stopped arbitrary-class
object injection but not forgery of real framework events: a page could POST
PaymentCompleted with its own amount, or Biometric\Completed(success: true),
and the app's listeners and pending fluent callbacks would fire as if the
device sent it. Reported by acd421 in a retest.

The native shell now signs each event with the app key (held in the
Keychain/Keystore, never exposed to the page) over a nonce, a timestamp and the
exact body. NativeEventSignature verifies the HMAC, rejects a stale or replayed
one (one-time nonce via the cache, 30s window), and the controller drops
anything that fails. Binding the signature to the body stops a script that
reads a genuine event's signature from pairing it with a different payload; the
one-time nonce stops replay.

Enforcement is gated on NATIVEPHP_EVENTS_SIGNED, which the native shell sets at
boot once it signs. An app that updates only the composer package keeps working
unsigned until it reinstalls the native project; the flag is process
environment, out of a page's reach, so it can't be downgraded. This commit is
the PHP half; the native signing (both twins setting the flag and computing the
HMAC) follows and is what turns enforcement on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A page can POST to /_native/api/events itself (the api routes carry no
middleware), so a script could forge a device event — a PaymentCompleted,
a Biometric\Completed(success: true), a scanner or camera result — and fire
the app's listeners and pending callbacks as if the device sent it.

The shell now signs every event it posts there. It builds the request body
itself by concatenation (so the posted bytes are exactly what it signs,
never rebuilt by JSON.stringify) and adds an app-key HMAC over a per-event
nonce, a timestamp and that body. The app key lives in the Keychain/Keystore
and never reaches the page. PHP recomputes the HMAC with the same key,
rejects anything stale, replayed or unsigned, and only then fires the event.

Enforcement is gated on NATIVEPHP_EVENTS_SIGNED, which the shell sets at
boot (iOS NativePHPApp.setupEnvironment, Android LaravelEnvironment). It is a
native setenv on the on-device embedded PHP, so it never reaches a shared PHP
bootstrap or the dev-machine PHP under Jump — there events stay unsigned and
enforcement stays off. Signing itself is also skipped while a Jump session is
active, so the dev loop keeps working.

The message layout, purpose string and header names match
Native\Mobile\Support\NativeEventSignature on the PHP side byte for byte.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@simonhamp
simonhamp merged commit 96703e0 into main Nov 5, 2026
6 checks passed
@simonhamp
simonhamp deleted the harden/native-event-auth branch November 5, 2026 15:31
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