Vulnerabilities in pkg:pypi/loopx

PACKAGE
Advisory Identifier: GHSA-vx2m-gpq4-8j5q
Advisory Summary

CVE-2026-108372 is a HIGH loopx serve-status permissive cors policy vulnerability (CWE-942: Permissive Cross-domain Security Policy with Untrusted Domains) affecting Loopx libraries in the PyPI ecosystem. The issue involves loopx serve-status permissive cors policy exposes control-plane state and private markdown files. Remediation is available by upgrading affected packages to version 0.4.5.

1 Known Advisory Record(s)
Package Vulnerability Intelligence

pkg:pypi/loopx

1 Known Advisory Record(s)

Vulnerability advisories affecting package pkg:pypi/loopx. Select an advisory below to view technical details or fixes.

Advisory ID CVE / NVD Severity Summary
GHSA-vx2m-gpq4-8j5q CVE-2026-108372 HIGH LoopX serve-status permissive CORS policy exposes control-plane state and private Markdown files

CVE-2026-108372 at a Glance

CVE Identifier CVE-2026-108372
GHSA Identifier GHSA-vx2m-gpq4-8j5q
Severity HIGH
CVSS v3.1 Base Score N/A
CWE Classification CWE-942: Permissive Cross-domain Security Policy with Untrusted Domains
Affected Product Loopx
Package Ecosystem PyPI
Published Date
Last Updated

How to Fix CVE-2026-108372

To remediate this vulnerability, upgrade all affected Loopx dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

loopx fixed: 0.4.5
pip install loopx>=0.4.5
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

pip show loopx

How to Check if You Are Affected

To determine whether your Python environment is affected by CVE-2026-108372, inspect your installed packages using pip:

pip show loopx

Analyze the terminal output to locate any version of Loopx in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 0.4.5), your application is affected.

Note: The pip show loopx command checks currently installed packages in your active Python environment.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-942: Permissive Cross-domain Security Policy with Untrusted Domains, where the product uses a web-client protection mechanism such as a content security policy (csp) or cross-domain policy file, but the policy includes untrusted domains with which the web client is allowed to communicate. In Loopx, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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. 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](https://github.com/aeonfun/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.

CVE-2026-108372 Affected Loopx Versions

1 component(s)
Fixed Versions:
0.4.5
Product / Package Ecosystem Affected Versions Fixed Version
loopx
PyPI
≥ 0.0.1
< 0.4.5
0.4.5

References

Frequently Asked Questions

What is CVE-2026-108372?
CVE-2026-108372 is a security vulnerability in Loopx involving LoopX serve-status permissive CORS policy exposes control-plane state and private Markdown files under CWE-942: Permissive Cross-domain Security Policy with Untrusted Domains.
Which Loopx versions are affected?
Affected versions include releases prior to 0.4.5 across affected packages: loopx.
What version fixes CVE-2026-108372?
Upgrading to 0.4.5 (or the latest patched release for your respective branch) resolves the vulnerability.
Is loopx affected?
Yes, loopx is affected in releases prior to 0.4.5. Upgrade your dependency to 0.4.5 or later.
What CWE is associated with CVE-2026-108372?
CVE-2026-108372 is classified under CWE-942: Permissive Cross-domain Security Policy with Untrusted Domains (The product uses a web-client protection mechanism such as a Content Security Policy (CSP) or cross-domain policy file, but the policy includes untrusted domains with which the web client is allowed to communicate.).
How can I check PyPI dependencies for CVE-2026-108372?
Run the dependency tree command: 'pip show loopx'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-vx2m-gpq4-8j5q",
  "modified" : "2026-10-19T16:11:57Z",
  "published" : "2026-10-19T16:11:57Z",
  "aliases" : [ "CVE-2026-108372" ],
  "summary" : "LoopX serve-status permissive CORS policy exposes control-plane state and private Markdown files",
  "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 — 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('http://127.0.0.1:8766/status.json')).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}&path=design-notes.md`\n  )).json();\n  exfiltrate(m.content);\n}\n```\n\nand read back:\n\n* **`GET /status.json`** — 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=…&path=…`** — the *content* of any `.md`/`.markdown`\n  file under a goal's repo root, state dir, or runtime dir. In practice that is the\n  operator's private design notes, handoff docs, and evidence write-ups.\n* **`GET /`** — endpoint discovery, including which write APIs are enabled.\n* **`POST /control-plane/configure-goal/dry-run`** — 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 — 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 — 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 — you know your users' setups better than I do.\n\n## Location\n\n`loopx/status_server.py:136-146` — `StatusRequestHandler._send_json()`:\n\n```python\ndef _send_json(self, payload: dict[str, Any], *, status: int = 200) -> None:\n    ...\n    self.send_header(\"Access-Control-Allow-Origin\", \"*\")   # <-- 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't 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` → 400; an absolute `.md` outside the\n  goal roots → 400 (\"outside the goal repo/state/runtime roots\"). `path_is_under()` is\n  correct.\n* **Extension allowlist holds.** `secrets.env` inside the repo → 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  -> 200  ACAO: '*'\nGET /                        Origin: https://evil.example  -> 200  ACAO: '*'\nGET /status.json             Origin: https://evil.example  -> 200  ACAO: '*'\nGET /review-material?goal_id=victim-goal&path=internal-design-notes.md\n                             Origin: https://evil.example  -> 200  ACAO: '*'\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 -> 403 (origin check)\nPOST /control-plane/configure-goal/apply   Origin: https://evil.example -> 403 (origin check)\nGET  /review-material  ../../../../etc/hostname                        -> 400 (traversal blocked)\nGET  /review-material  <abs path outside roots>.md                     -> 400 (root check)\nGET  /review-material  secrets.env                                     -> 400 (extension check)\nGET  /status.json                          Origin: https://evil.example -> 200 ACAO '*'  <-- 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 — `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) -> 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) -> 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 — 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 — 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.",
  "severity" : [ {
    "type" : "CVSS_V4",
    "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"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "PyPI",
      "name" : "loopx"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0.0.1"
      }, {
        "fixed" : "0.4.5"
      } ]
    } ]
  } ],
  "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"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-346", "CWE-942" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-10-19T16:11:57Z",
    "nvd_published_at" : "2026-10-16T22:17:14Z"
  }
}