Skip to content

Harden the web-view-reachable bridge surface - #609

Merged
simonhamp merged 4 commits into
mainfrom
security/web-view-hardening
Oct 25, 2026
Merged

simonhamp merged 4 commits into
mainfrom
security/web-view-hardening

Conversation

@simonhamp

@simonhamp simonhamp commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

Hardening for the parts of the bridge a web view can reach. These matter because routes/api.php is registered with no middleware (no CSRF, session or auth), and external navigations open in the system browser, so the attacker in scope is script running on the app's own origin: an XSS in the app, or a third-party script the app bundles. That script can POST freely to /_native/api/call and /_native/api/events.

Four fixes. The PHP-side changes ship in this package; the Swift/Kotlin/C changes only reach an app after native:install --force and a rebuild.

1. Seal async task payloads with the app key

The background runner used to unserialize() whatever it found in storage/framework/async/payload/<id>.task. Anything able to write that file could have the app run it.

The payload is now encrypted with Laravel's encrypter under the current app key, bound to a purpose prefix and the task id. The runner only runs a payload this app sealed for that task, and drops anything else (a planted file, a payload moved to another id, or something else the app key encrypted). readPayload() only looks up UUID ids, since the id becomes part of a path. An app with no app key can no longer dispatch async tasks; it fails through ->failed(). On device there is always a key.

2. Stop a web view page driving the async bridge

AsyncTask.Dispatch and AsyncTask.Complete are registered bridge functions, so a page could call them through /_native/api/call.

  • The task id is spliced into PHP the async lane evaluates, so it must now be a UUID before it reaches that lane (refused in Swift, Kotlin and the C lane). A non-UUID id was a code-injection route.
  • AsyncTask.Complete now only answers calls made on an async-lane thread, so a page can't post an arbitrary event into a native screen's runloop.
  • The timeout path always posts AsyncTaskFailed with the task's own id; native no longer takes an event name from the call.
  • NativeCallController refuses the AsyncTask. prefix from a web view.

3. Gate /_native/api/events to known event classes

DispatchEventFromAppController instantiated any class a page named, with named constructor arguments from the page, gated only by class_exists(). That is an arbitrary-object-instantiation gadget (for example SplFileObject in write mode truncates or creates a file at any writable path).

A new marker interface, Native\Mobile\Events\Contracts\DispatchableFromNative, is now required; the endpoint refuses any class that doesn't implement it. The 26 core device-event classes implement it, and BroadcastsGlobally extends it so app and plugin global events are unchanged.

Behaviour note (patch-level): a plugin that dispatches its own non-global event class through this endpoint must add the marker to that class.

4. Confine File.Move / File.Copy, and the /_assets path

File.Move and File.Copy are exposed in the JS bridge, so pages may call them, and they took unrestricted absolute paths. Separately, the iOS /_assets handler joined the URL path onto public/ without removing ...

  • The file functions now canonicalise from/to (resolving .. and symlinks), require the source in temp/cache/storage and the destination in app storage, and refuse public/, .env, *.sqlite, bootstrap/cache, storage/framework and storage/logs. Legitimate capture-to-storage moves still work. This closes both the copy-into-public/-and-read-back exfil and the overwrite/DoS.
  • PHPSchemeHandler (iOS) and PHPWebViewClient (Android) canonicalise the asset path and confine it to public/, so a request can't escape it.

Notes for review

  • Affected versions: the async findings (1, 2) exist from 4.5.0; the events and file findings (3, 4) are older. Patched in 4.6.1.
  • Tests: AsyncTaskBridgeTest, NativeCallControllerTest, AssetPathTraversalTest, FileFunctionsConfinementTest, plus additions to the async transport and completion tests. The new tests fail against main.
  • Full Pest suite green, Pint and PHPStan clean. iOS Debug and unsigned Release built; Android release AAB built with the changed classes compiled and :app:testDebugUnitTest green. The iOS /_assets traversal fix was confirmed on a simulator (a crafted /_assets/.. request returns 404 instead of the file).

🤖 Generated with Claude Code

simonhamp and others added 3 commits October 25, 2026 13:31
The async runner unserialized whatever it found in
storage/framework/async/payload/<id>.task, so a file written there by
anything other than the app would still be run.

The payload is now encrypted with Laravel's encrypter under the current
app key before it is spooled. The plaintext carries a purpose prefix and
the task id, so a payload only opens for the task it was sealed for, and
nothing else the app key encrypted (cookies, encrypted casts) opens as a
task. readPayload() drops anything that doesn't open, logs a warning,
and only looks up ids that are UUIDs, since the id becomes a path.

An app with no app key can no longer dispatch. The dispatch fails at the
call site through ->failed(), the same way a refused dispatch does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A page in a web view can call any registered bridge function through
/_native/api/call, and that includes AsyncTask.Dispatch and
AsyncTask.Complete.

Dispatch took any string as the task id. Both C lanes splice the id
into PHP that they evaluate, so a quote in it ends the string and the
rest is parsed as code. Swift and Kotlin now refuse an id that isn't a
UUID, and so does the C lane right before the splice.

Complete posted any event name and payload into a native screen's
runloop. That covers __deeplink navigation and one-shot callbacks that
are looked up by event class alone. It now answers only calls made on
an async lane thread. A bridge call runs on the thread of the PHP that
made it, so the lane's own threads are marked: a __thread flag in
PHP.c on iOS, and a ThreadLocal in AsyncTaskExecutor on Android.
AsyncTaskTransport::complete() logs a warning if native refuses.

Dispatch's timeout event was the same sink, delayed. Native no longer
reads timeoutEvent and always posts AsyncTaskFailed with the task's own
id. PHP still sends the name for native projects installed earlier.

NativeCallController also refuses the AsyncTask. prefix with a 403.
composer update leaves nativephp/ios and nativephp/android alone, so
the native changes only reach an app after native:install --force. The
controller check ships with the package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JavaScript running in a web view can POST to the `api` routes, which carry
no middleware, so a page (via an XSS or an included third-party script) can
reach every bridge the front end can. Three of those sinks took page input
without confining it.

1. `/_native/api/events` constructed the event class named in the request and
   fired it, gated only on `class_exists()`. A page could name any autoloadable
   class and hand it constructor arguments of its own — an object-injection
   gadget. `SplFileObject` in write mode truncates or creates any file the app
   can write. A new marker interface, `Events\Contracts\DispatchableFromNative`,
   now gates the endpoint: it refuses a class that does not implement it. Every
   device event the native side posts implements it, and `BroadcastsGlobally`
   extends it so an app's own global events keep working unchanged.

2. `File.Move` / `File.Copy` took any `from`/`to`. Left open that is a file
   exfil primitive (copy `.env` or the SQLite database into `public/`, read it
   back over the app origin at `/_assets/…`) and a clobber/DoS primitive. These
   are exported in the JS bridge, so a page is meant to call them and a blanket
   block would break apps — the paths are confined in the native twins instead.
   A source must resolve inside a scratch dir (temp/cache) or app storage, a
   destination inside app storage; secrets sit outside those roots. Paths are
   canonicalised first (symlinks and `..` resolved), so neither `..` nor a
   symlink can walk out of a root.

3. The `/_assets/<path>` handlers joined the request path onto `public/` by
   string concatenation with no `..` check. `/_assets/..%2f.env` (WebKit leaves
   the `%2f`-smuggled `..` in the path) escaped `public/` and returned any file
   the app process could read — `.env`, the database, PHP source. Both handlers
   (iOS scheme handler, Android web-view client) now canonicalise the resolved
   path and serve only when it stays inside `public/`; a miss falls through to
   PHP, which 404s. Verified on a simulator: `/_assets/..%2f.env` returns 404
   with nothing leaked, where it returned 200 and the real `.env` before.

Breaking change (patch): a third-party plugin that dispatches its own event
class back to PHP through `/_native/api/events` must now have that class
implement `DispatchableFromNative` (or extend `BroadcastsGlobally`). Core and
global events already do.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@simonhamp simonhamp changed the title Harden the async task bridge against web view input Harden the web-view-reachable bridge surface Oct 25, 2026
…tract

The destination root was Documents/app/storage/app on iOS and a guessed
laravel/... path on Android. Neither is where storage_path('app') actually
points: both platforms override Laravel's storage path to a persistent
app-private location via LARAVEL_STORAGE_PATH (Application Support/storage on
iOS, getDir("storage")/persisted_data/storage on Android). Documents/app is
also wiped on every update.

So the old root both rejected legitimate moves into storage_path('app') and,
on anything that did land there, lost it on the next update. The root now
derives from LARAVEL_STORAGE_PATH + /app on both platforms, matching where the
app really reads and writes. A regression test pins this so it can't drift back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@simonhamp
simonhamp merged commit 31f9452 into main Oct 25, 2026
6 checks passed
@simonhamp
simonhamp deleted the security/web-view-hardening branch October 25, 2026 15:55
gwleuverink added a commit that referenced this pull request Nov 2, 2026
Covers mobile PRs #525, #527, #528, #560, #605, #608, #609, #611, #613, #614, #615, #616, #617, #619, #621, #622, #623, #624, #625, #626, #627, #628, #629, #632, #633, #634, #635 and #636, and the commits on main without a PR: 4082538 (bounded()'s docblock), 49528ba (the Android logging fix from a fork), b23aba9, 2a773cc and b9316b6 (security policy, issue template and README links). Main b107ed9..767a7c8, 33 commits, 4.6.1 among them.

Ported to core on branch sync/mobile-main-2026-11-01, which this merge needs: #608 (scrollVirtualListTo()), #625 with 4082538 (snap points on a gesture area), #621 (:context-menu on a pressable, matched through core's ConvertsToElement as :menu is), #617's farewell frame through a hook, and #614's ValidatesInput and FieldErrors, which move to core. The old names of those two resolve to core's through the alias shim.

Kept on this branch: #616 in full. EVENT_SYSTEM_BACK sits on NativeComponent next to EVENT_HOT_RELOAD and EVENT_SHUTDOWN, the router half is in NativeRouter, and InteractsWithAppShell's back button handler checks the constant, as main's runLoop() does. #617's element types: NativeComponent answers core's typesClosedInFarewellFrame() hook with bottom_sheet and modal. BroadcastsGlobally extends core's marker and #609's DispatchableFromNative.

Removed again in the merge: HasVirtualListWindow, GestureArea and Pressable (main changed them, core has them), and ValidatesInput and FieldErrors (main added them, core has them). Main's import of the old dispatcher name came out of DispatchEventFromAppController, where it clashed with core's.

Adapted for the extraction: BroadcastsGloballyNamesTest expects DispatchableFromNative among the old marker's interfaces, as #609 makes it.

#609 is merged as main has it and is not ported.

Needs NativePHP/core#18.

Tests, all on PHP 8.4.27, with dev dependencies updated for #628 (Laravel 12.70.2, Pest 3.8.7): mobile on this merge against the core sync branch (445049b), 2,467 passed, 4 risky, 4 skipped. PHPStan clean. Mobile main (767a7c8), 2,412 passed, 4 risky, 4 skipped. Desktop main (fd40580) against the core sync branch, test:php 2,240 passed, as against core main. Desktop #57 (977a2af), 2,245 passed against the earlier core sync head (59c73fc), which differs only by the constant moved to mobile; not rerun.
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