GHSA-vx2m-gpq4-8j5q: Path Traversal
Summary
loopx serve-status sends Access-Control-Allow-Origin: on every response, including its unauthenticated GET routes. Because the endpoint is unauthenticated and bound to a fixed, well-known loopback port, any website the operator visits can read LoopX control-plane state and the contents of Markdown files inside every registered goal repo, using a plain fetch() with no user interaction.
The write endpoints are not affected — they are correctly Origin-checked. This report is strictly about the read paths.
Impact
While loopx serve-status is running, a page on any origin can run:
js // no interaction, no preflight, no credentials needed const s = await (await fetch('http://127.0.0.1:8766/status.json')).json(); for (const g of s.goals ?? []) { const m = await (await fetch( http://127.0.0.1:8766/review-material?goalid=${g.id}&path=design-notes.md )).json(); exfiltrate(m.content); }
and read back:
GET /status.json — the whole status projection: goal ids, run history, and local absolute paths (registry, runtimeroot). With --global-registry this covers every project registered on the machine, not just one. GET /review-material?goalid=…&path=… — the content of any .md/.markdown file under a goal's repo root, state dir, or runtime dir. In practice that is the operator's private design notes, handoff docs, and evidence write-ups. GET / — endpoint discovery, including which write APIs are enabled. POST /control-plane/configure-goal/dry-run — this route has no Origin check at all, and doOPTIONS returns Access-Control-Allow-Headers: Content-Type, so a cross-origin JSON POST passes preflight. The response leaks the before/after control-plane configuration for a goal. (It does not write — I verified written=false.)
docs/public-private-boundary.md classifies "local absolute paths", "task ids", "raw logs and metrics" and "active goal state that reveals current user context" as material that must stay private. All of it is currently readable by any origin.
Two things make this more than theoretical:
1. Fixed default ports. 8765 (project) and 8766 (global) are hardcoded defaults, so an attacker page can simply try both — no discovery step needed. 2. It is documented as a persistent service. docs/guides/getting-started.md documents loopx serve-status, and scripts/macos-dashboard-launchagent.sh installs a LaunchAgent that keeps serve-status --global-registry --port 8766 running after every login. For anyone following that guide, this is an always-on, machine-wide read surface.
I have graded this High on the basis of zero-interaction exfiltration of private local material from a persistent service. It is read-only and scope-limited, so if you read the exposure as lower I would not argue the point — you know your users' setups better than I do.
Location
loopx/statusserver.py:136-146 — StatusRequestHandler.sendjson():
python def sendjson(self, payload: dict[str, Any], , status: int = 200) -> None: ... self.sendheader("Access-Control-Allow-Origin", "") # <-- line 142
sendjson is the single response path for all routes, so the wildcard is applied to /status.json, /review-material, /healthz, / and the dry-run POSTs alike. doOPTIONS (:148-153) repeats it.
Confirmed present at main HEAD (b0c8e315, blob c9e53d1a) at the time of writing.
What is NOT affected (I checked, so you don't have to)
I tried to refute this before reporting. These controls all hold:
Writes are properly defended. With --enable-reward-write-api and --enable-control-plane-write-api both on, POST /reward/append and POST /control-plane/configure-goal/apply return 403 "only accepts loopback browser origins" for a foreign Origin. isloopbackorigin() does its job. There is no cross-origin write, and DNS rebinding does not defeat it either (the rebound page still carries its own non-loopback origin). No path traversal. ../../../../etc/hostname → 400; an absolute .md outside the goal roots → 400 ("outside the goal repo/state/runtime roots"). pathisunder() is correct. Extension allowlist holds. secrets.env inside the repo → 400. Only .md/.markdown can be read. Dry-run does not write. configuregoalwithglobalsync(execute=False) returns before any write; I observed written=false.
So the exposure is confined to: status JSON + Markdown under registered goal roots. That is still private material, but it is not arbitrary file read and it is not remote code execution.
Proof of concept
Run against an unmodified clone at HEAD, default flags (no write APIs enabled):
GET /healthz Origin: https://evil.example -> 200 ACAO: '' GET / Origin: https://evil.example -> 200 ACAO: '' GET /status.json Origin: https://evil.example -> 200 ACAO: '' GET /review-material?goalid=victim-goal&path=internal-design-notes.md Origin: https://evil.example -> 200 ACAO: '' content: "# Internal notes\n\nPRODDBHOST=db.internal.corp\nCONFIDENTIAL: ..."
The file content came back verbatim to a foreign origin. Matched controls from the same harness, with both write flags enabled (i.e. the configuration most favourable to the current design):
POST /reward/append Origin: https://evil.example -> 403 (origin check) POST /control-plane/configure-goal/apply Origin: https://evil.example -> 403 (origin check) GET /review-material ../../../../etc/hostname -> 400 (traversal blocked) GET /review-material <abs path outside roots>.md -> 400 (root check) GET /review-material secrets.env -> 400 (extension check) GET /status.json Origin: https://evil.example -> 200 ACAO '' <-- still readable
I am happy to send the exact harness script if it is useful; it is ~120 lines of stdlib Python and touches no file in the repo.
Suggested fix
You already have the right predicate in this file — isloopbackorigin(). The minimal fix is to stop emitting the wildcard and instead echo the request origin only when it passes that check:
python def sendjson(self, payload: dict[str, Any], , status: int = 200) -> None: body = json.dumps(payload, ensureascii=False, indent=2).encode("utf-8") self.sendresponse(status) self.sendheader("Content-Type", "application/json; charset=utf-8") self.sendheader("Content-Length", str(len(body))) self.sendheader("Cache-Control", "no-store") origin = self.headers.get("Origin") if origin and isloopbackorigin(origin): self.sendheader("Access-Control-Allow-Origin", origin) self.sendheader("Vary", "Origin") # no Origin header (curl, the CLI, a same-origin fetch) -> send no ACAO at all self.endheaders() self.wfile.write(body)
and the same in doOPTIONS. This keeps the dashboard working — it is served from http://127.0.0.1:5174 and fetches 127.0.0.1:8765/8766, which is cross-origin between two loopback ports, which I assume is exactly why the wildcard was added — while denying every non-loopback origin.
Two optional hardening steps, if you want them:
Apply the existing isloopbackorigin() check to POST /control-plane/configure-goal/dry-run and POST /reward/dry-run too, so the config projection is not readable cross-origin. Add a Host header check (reject a Host that is not loopback) to close DNS rebinding against clients that do not send Origin.
Scope of review
Reviewed 26/52 identified entrypoints across 758 files (360,618 LOC) of the loopx package and dashboard; tools: semgrep 1.172.0, trufflehog 3.96.0, osv-scanner 2.4.0, plus a manual dataflow pass. Skipped: tests/, regression/, examples/, docs/, and the benchmark adapters under loopx/benchmarkadapters/. Coverage confidence: partial.
Other things I looked at and did not find a problem with, in case it saves you time: self-update (curl | bash is over HTTPS to a hardcoded raw.githubusercontent.com URL; the LOOPXARCHIVEURL override requires environment control, i.e. privileges the attacker would have to already have); project/slash-command skill installation (strict id regex plus assertprojectboundary() containment); the shell=True worker tick (the value is a user-configured shell command by design, and demosupervisor.buildvisibleworkerturncommand passes the objective through shlex.quote); outbound SSRF surfaces (contentops/surface.py blocks private/loopback/link-local ranges and refuses redirects). No pickle, yaml.load, eval, or os.system anywhere in the package.
3 dependency advisories exist in dev/docs tooling only (pymdown-extensions, idna, postcss); none are reachable from the runtime package, which declares no dependencies, so I have not opened a bump PR.
Detected by
Aeon, an automated security review. Findings are reproduced at HEAD and manually verified before reporting. Reported privately via GitHub PVR because that is the intake channel your SECURITY.md designates.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/loopxto a version that resolves this vulnerability.Fixed in 0.4.5 - Configuration
Replace the wildcard ACAO response in StatusRequestHandler._send_json() with loopback-origin validation, and keep Vary: Origin.
loopx serve-status Access-Control-Allow-Origin = Echo the request Origin only when is_loopback_origin(origin) passes; send no ACAO header when Origin is absent or non-loopback - Configuration
Apply the existing is_loopback_origin() check to the dry-run POST route and reject non-loopback browser origins.
POST /control-plane/configure-goal/dry-run Origin validation = is_loopback_origin() - Configuration
Add a Host header check that rejects any Host value that is not loopback to mitigate DNS rebinding.
loopx serve-status Host header validation = loopback only
Event History
Frequently Asked Questions
Who is exposed to this issue?
Operators are exposed while `loopx serve-status` is running and reachable on its fixed loopback port. Any website they visit can make browser requests to the local service and read its unauthenticated GET responses.
What information can a malicious website read?
It can read `/status.json`, including goal IDs, run history, registry and runtime-root absolute paths. It can also request review material for goals and read Markdown content from registered goal repositories; with `--global-registry`, status data covers every project registered on the machine.
Does exploitation require credentials, user interaction, or access to write endpoints?
No. A malicious page can use a plain browser `fetch()` with no credentials, no preflight request, and no user interaction; the affected scope is read-only, because write endpoints are Origin-checked.