Skip to content

fix(desktop_bridge): restrict /path/open to known kinds (CWE-78)#650

Open
sebastiondev wants to merge 1 commit into
lsdefine:mainfrom
sebastiondev:fix/cwe78-desktop-bridge-unauthenti-0799
Open

fix(desktop_bridge): restrict /path/open to known kinds (CWE-78)#650
sebastiondev wants to merge 1 commit into
lsdefine:mainfrom
sebastiondev:fix/cwe78-desktop-bridge-unauthenti-0799

Conversation

@sebastiondev

Copy link
Copy Markdown

Summary

The /path/open handler in frontends/desktop_bridge.py accepts an attacker-controlled file path and feeds it to the host OS launcher (os.startfile on Windows, open/xdg-open on macOS/Linux) without restricting which file is launched. The desktop bridge is an aiohttp server bound to 127.0.0.1:14168 with Access-Control-Allow-Origin: * and no authentication on this route, so any web page the user visits while GA is running can fetch() it cross-origin and trigger an arbitrary local-file launch on the user's machine.

This PR restricts path_open_handler to the three kind values the frontend actually uses (mykey, mykeyTemplate, upload). Everything else now returns 403 unsupported kind before the path ever reaches a launcher.

Vulnerability details

  • File / function: frontends/desktop_bridge.py::path_open_handler (registered at app.router.add_post("/path/open", path_open_handler), line 1795).
  • Class: unauthenticated drive-by OS-launcher reachable cross-origin via CORS *. Closest CWEs are CWE-749 (exposed dangerous method) and CWE-352 (CSRF) chained to an OS-launch primitive. The advisory title uses CWE-78 because the downstream effect is "attacker controls argument to an OS-level command launcher," even though no shell metacharacter parsing is involved.
  • Affected sink helpers: _open_path_default (line 1610) calls os.startfile(path) / subprocess.Popen(["open", path]) / subprocess.Popen(["xdg-open", path]). _open_path_in_editor (line 1573) does the same with the Windows "edit" verb plus Notepad/Cursor/VS Code fallbacks. _reveal_path_in_file_manager (line 1596) invokes explorer /select,<path> or open -R <path>.
  • Data flow (pre-fix):
    1. data = await read_json(request) — body of the POST is fully attacker-controlled.
    2. kind = data.get("kind", "") — when kind was not one of the three known values, the else: branch ran target = Path(data.get("path") or data.get("target") or manager.ga_root).
    3. target = target.resolve(), gated only by target.exists() — no path-prefix check.
    4. Depending on mode, control flowed to _reveal_path_in_file_manager(target) / _open_path_default(target) / _open_path_in_editor(target), each of which hands the path to the OS launcher.

Why it is reachable from a drive-by web page

  • The server is started with web.run_app(create_app(), host=os.environ.get("BRIDGE_HOST", "127.0.0.1"), port=int(os.environ.get("BRIDGE_PORT", "14168"))) — loopback by default but reachable from any process on the machine, including any browser tab.
  • The single middleware on the app is cors_middleware (app = web.Application(middlewares=[cors_middleware], ...), line 1772). It responds to every preflight with Access-Control-Allow-Origin: *, Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS, Access-Control-Allow-Headers: Content-Type. A cross-origin fetch(..., {method: "POST", headers: {"Content-Type": "application/json"}}) therefore passes preflight and is delivered.
  • The handler has no _is_local_peer check. Only stop_extras_handler, start_extras_handler, and bridge_exit_handler opt into that guard; path_open_handler does not.
  • No CSRF token, no Origin/Referer validation, no session binding.

Fix

_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)
    ...

The old else: target = Path(data.get("path") or data.get("target") or manager.ga_root) catch-all is removed. The upload branch keeps the existing resolved.relative_to(upload_root) containment check, so a legitimate kind=upload request still cannot escape the per-session upload directory.

Diff is +14 / −3 in a single file, no new dependencies, no behavioural change for any caller the frontend already issues.

Tests

Added tests/test_path_open_handler_cwe78.py (5 cases) that exercises the handler with an in-process aiohttp test server and mocks the three launcher helpers so they are never actually invoked during the test run:

  1. test_unknown_kind_returns_403_and_no_launcher_call — iterates kind ∈ {"", "evil", "exec", None, "system"} with path="/usr/bin/calc.exe" and asserts 403 unsupported kind and that none of _open_path_default / _open_path_in_editor / _reveal_path_in_file_manager are called.
  2. test_missing_kind_field_returns_403 — POST body with no kind field at all (the original catch-all branch).
  3. test_upload_outside_dir_returns_403 — regression for the existing upload traversal guard.
  4. test_upload_inside_dir_launches — drops a real file in the configured upload dir and confirms kind=upload still launches it via _open_path_default.
  5. test_mykey_template_kind_launches_editor — confirms kind=mykeyTemplate still resolves and reaches the editor launcher.

Results:

$ python3 -m pytest tests/test_path_open_handler_cwe78.py -v
...
tests/test_path_open_handler_cwe78.py::PathOpenHandlerTests::test_missing_kind_field_returns_403 PASSED
tests/test_path_open_handler_cwe78.py::PathOpenHandlerTests::test_mykey_template_kind_launches_editor PASSED
tests/test_path_open_handler_cwe78.py::PathOpenHandlerTests::test_unknown_kind_returns_403_and_no_launcher_call PASSED
tests/test_path_open_handler_cwe78.py::PathOpenHandlerTests::test_upload_inside_dir_launches PASSED
tests/test_path_open_handler_cwe78.py::PathOpenHandlerTests::test_upload_outside_dir_returns_403 PASSED
============================== 5 passed in 0.06s ===============================

Running the same suite against the pre-fix desktop_bridge.py produces 2 failed, 3 passed — the two new "unknown kind" cases legitimately reach the launcher (the first one hits the target.exists() gate and returns 404, the second falls through to the launcher path), confirming the tests are real regression coverage for this bug rather than tautologies.

Proof of concept

With the bridge running on its default 127.0.0.1:14168 (e.g. you launched GA), have the user visit a page containing:

<!doctype html>
<script>
fetch("http://127.0.0.1:14168/path/open", {
  method: "POST",
  headers: {"Content-Type": "application/json"},
  // No credentials needed — the route has no auth.
  body: JSON.stringify({
    kind: "drive-by",                       // anything not in the allow-list
    path: "C:\\Windows\\System32\\calc.exe", // or /System/Applications/Calculator.app on macOS,
                                            // or /usr/bin/xcalc on Linux
    mode: "open"
  })
}).then(r => r.json()).then(console.log);
</script>

Pre-fix: the CORS preflight succeeds, the POST is delivered, kind falls into the catch-all else, target = Path("C:\\Windows\\System32\\calc.exe").resolve(), target.exists() is true on a Windows box, and _open_path_default(target) calls os.startfile(target) — calc.exe launches. Equivalent paths reproduce on macOS (open /System/Applications/Calculator.app) and Linux (xdg-open /usr/bin/xcalc). An attacker who can stage a file in a predictable spot (e.g. a recent download in ~/Downloads) can launch arbitrary executables / .lnk shortcuts / .url files registered with the OS.

Post-fix: the same request returns {"ok": false, "error": "unsupported kind"} with HTTP 403 and the launcher is never invoked. The PoC HTML can be served from any origin — file://, http://attacker.example, an iframe on a compromised site, etc.

Adversarial review

Before submitting, I tried hard to disprove the finding:

  • Is there a framework-level auth gate I missed? No. app.middlewares contains only cors_middleware. Per-handler _is_local_peer checks exist on three specific routes (stop-extras, start-extras, bridge/exit) but not on /path/open. grep -n 'app.middlewares\|_is_local_peer' frontends/desktop_bridge.py confirms it.
  • Does the loopback bind save us? No — loopback only blocks remote attackers. Any browser tab the user opens runs on the same loopback interface, which is exactly the threat model the CORS * header opens up.
  • Does the browser block this as "no-cors" or a forbidden header? No. The request is application/json, which triggers a CORS preflight; the bridge's preflight response explicitly allows POST, Content-Type, and * origin, so the browser delivers the request.
  • Is the target.exists() check enough? No. It only blocks launching non-existent paths. Any pre-existing OS binary (calc.exe, xcalc, anything in PATH) or attacker-staged file (a download in ~/Downloads, a Word doc that runs macros, a .url/.lnk shortcut) satisfies it.
  • Is this redundant with other unauthenticated routes on the same bridge? This is the honest caveat: yes, the same bridge exposes other unauthenticated handlers an attacker could chain (mykey rewrites, session prompts). The architectural root cause is the unauthenticated CORS-* loopback bridge itself, and a follow-up that adds a router-level Origin/CSRF gate would close the rest. That said, this handler is the only one whose direct side effect is "OS launcher executes an attacker-named file" with no LLM/agent in the loop, so closing it is a real reduction of attack surface that is worth landing on its own. Happy to send a follow-up PR adding an Origin allow-list at the middleware layer if you'd like.

Checklist (per CONTRIBUTING.md)

  • Context explained above in ≤3 sentences at the top of this PR.
  • Change radius: 1 file, +14 / −3 lines. New allow-list is a single frozenset constant; the handler keeps its existing structure.
  • No new dependencies.
  • On failure (unknown kind), the response is loud and specific (403, error: "unsupported kind"), matching the "fail loud" principle.

Discovered by the Sebastion AI GitHub App.

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.
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