fix(desktop_bridge): restrict /path/open to known kinds (CWE-78)#650
Open
sebastiondev wants to merge 1 commit into
Open
fix(desktop_bridge): restrict /path/open to known kinds (CWE-78)#650sebastiondev wants to merge 1 commit into
sebastiondev wants to merge 1 commit into
Conversation
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.
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.
Summary
The
/path/openhandler infrontends/desktop_bridge.pyaccepts an attacker-controlled file path and feeds it to the host OS launcher (os.startfileon Windows,open/xdg-openon macOS/Linux) without restricting which file is launched. The desktop bridge is an aiohttp server bound to127.0.0.1:14168withAccess-Control-Allow-Origin: *and no authentication on this route, so any web page the user visits while GA is running canfetch()it cross-origin and trigger an arbitrary local-file launch on the user's machine.This PR restricts
path_open_handlerto the threekindvalues the frontend actually uses (mykey,mykeyTemplate,upload). Everything else now returns403 unsupported kindbefore the path ever reaches a launcher.Vulnerability details
frontends/desktop_bridge.py::path_open_handler(registered atapp.router.add_post("/path/open", path_open_handler), line 1795).*. 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._open_path_default(line 1610) callsos.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) invokesexplorer /select,<path>oropen -R <path>.data = await read_json(request)— body of the POST is fully attacker-controlled.kind = data.get("kind", "")— whenkindwas not one of the three known values, theelse:branch rantarget = Path(data.get("path") or data.get("target") or manager.ga_root).target = target.resolve(), gated only bytarget.exists()— no path-prefix check.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
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.cors_middleware(app = web.Application(middlewares=[cors_middleware], ...), line 1772). It responds to every preflight withAccess-Control-Allow-Origin: *,Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS,Access-Control-Allow-Headers: Content-Type. A cross-originfetch(..., {method: "POST", headers: {"Content-Type": "application/json"}})therefore passes preflight and is delivered._is_local_peercheck. Onlystop_extras_handler,start_extras_handler, andbridge_exit_handleropt into that guard;path_open_handlerdoes not.Origin/Referervalidation, no session binding.Fix
The old
else: target = Path(data.get("path") or data.get("target") or manager.ga_root)catch-all is removed. Theuploadbranch keeps the existingresolved.relative_to(upload_root)containment check, so a legitimatekind=uploadrequest 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:test_unknown_kind_returns_403_and_no_launcher_call— iterateskind ∈ {"", "evil", "exec", None, "system"}withpath="/usr/bin/calc.exe"and asserts403 unsupported kindand that none of_open_path_default/_open_path_in_editor/_reveal_path_in_file_managerare called.test_missing_kind_field_returns_403— POST body with nokindfield at all (the original catch-all branch).test_upload_outside_dir_returns_403— regression for the existing upload traversal guard.test_upload_inside_dir_launches— drops a real file in the configured upload dir and confirmskind=uploadstill launches it via_open_path_default.test_mykey_template_kind_launches_editor— confirmskind=mykeyTemplatestill resolves and reaches the editor launcher.Results:
Running the same suite against the pre-fix
desktop_bridge.pyproduces2 failed, 3 passed— the two new "unknown kind" cases legitimately reach the launcher (the first one hits thetarget.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:Pre-fix: the CORS preflight succeeds, the POST is delivered,
kindfalls into the catch-allelse,target = Path("C:\\Windows\\System32\\calc.exe").resolve(),target.exists()is true on a Windows box, and_open_path_default(target)callsos.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 /.lnkshortcuts /.urlfiles 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, aniframeon a compromised site, etc.Adversarial review
Before submitting, I tried hard to disprove the finding:
app.middlewarescontains onlycors_middleware. Per-handler_is_local_peerchecks 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.pyconfirms it.*header opens up.application/json, which triggers a CORS preflight; the bridge's preflight response explicitly allowsPOST,Content-Type, and*origin, so the browser delivers the request.target.exists()check enough? No. It only blocks launching non-existent paths. Any pre-existing OS binary (calc.exe,xcalc, anything inPATH) or attacker-staged file (a download in~/Downloads, a Word doc that runs macros, a.url/.lnkshortcut) satisfies it.*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)frozensetconstant; the handler keeps its existing structure.kind), the response is loud and specific (403,error: "unsupported kind"), matching the "fail loud" principle.Discovered by the Sebastion AI GitHub App.