From 216703c69f5da8c31f4837279b16d3bae8f8fd58 Mon Sep 17 00:00:00 2001 From: Sebastion Date: Mon, 29 Jun 2026 13:51:35 +0100 Subject: [PATCH] fix(desktop_bridge): restrict /path/open to known kinds (CWE-78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop frontend uses POST /path/open only with kind in {mykey, mykeyTemplate, upload} (see frontends/desktop/static/app.js and ga-web.js). The pre-fix handler fell through to an else branch that resolved an arbitrary attacker-supplied 'path' / 'target' field and launched it with os.startfile / 'open' / 'xdg-open'. The bridge listens on 127.0.0.1:14168 with Access-Control-Allow-Origin: '*' and no authentication. Combined with aiohttp.request.json() ignoring the Content-Type, a CORS 'simple request' (text/plain body) from any drive-by web page – or any host on the LAN if BRIDGE_HOST is exposed – could POST an arbitrary absolute path here and have the user's machine launch it. On Windows os.startfile happily executes .exe / .bat / .lnk / .url; on macOS and Linux 'open' / 'xdg-open' honor desktop file handlers. This patch reduces the attack surface to the three call sites the client actually uses by rejecting any other 'kind' with HTTP 403. --- frontends/desktop_bridge.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/frontends/desktop_bridge.py b/frontends/desktop_bridge.py index 2ff937aa3..8e514b95f 100644 --- a/frontends/desktop_bridge.py +++ b/frontends/desktop_bridge.py @@ -1391,10 +1391,23 @@ async def plan_handler(request): return json_ok(manager.plan_snapshot(sid)) +# The desktop frontend only ever asks /path/open to launch one of three +# well-known targets: the user's mykey.py, its template, or a file that lives +# under the per-session upload directory. Any other ``kind`` value is treated +# as an attacker probing for a path → OS-launcher gadget — the bridge listens +# on a loopback socket with ``Access-Control-Allow-Origin: *`` and no +# authentication, so a drive-by web page could otherwise POST an arbitrary +# absolute path here and have ``os.startfile`` / ``open`` / ``xdg-open`` +# launch it on the user's machine (CWE-78). +_PATH_OPEN_ALLOWED_KINDS = frozenset({"mykey", "mykeyTemplate", "upload"}) + + async def path_open_handler(request): data = await read_json(request) kind = data.get("kind", "") mode = data.get("mode", "open") + if kind not in _PATH_OPEN_ALLOWED_KINDS: + return json_ok({"ok": False, "error": "unsupported kind"}, status=403) if kind == "mykey": target = Path(manager.ga_root) / "mykey.py" if not target.exists(): @@ -1402,7 +1415,7 @@ async def path_open_handler(request): target = template if template.exists() else target elif kind == "mykeyTemplate": target = Path(manager.ga_root) / "mykey_template.py" - elif kind == "upload": + else: # kind == "upload" raw = Path(data.get("path") or "") try: resolved = raw.resolve() @@ -1411,8 +1424,6 @@ async def path_open_handler(request): except (ValueError, OSError): return json_ok({"ok": False, "error": "path not in upload dir"}, status=403) target = resolved - else: - target = Path(data.get("path") or data.get("target") or manager.ga_root) target = target.resolve() if not target.exists(): return json_ok({"ok": False, "error": f"File not found: {target}"}, status=404)