Repository navigation
Authenticate device events posted to /_native/api/events - #653
Merged
Merged
Conversation
/_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
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>
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.
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 —
PaymentCompletedwith 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.
NativeEventSignatureverifies 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 needsnative:install --force+ rebuild. Tests cover signed/unsigned/tampered-body/stale/replay/bad-key.🤖 Generated with Claude Code