Your Browser Can Reach the Agent: Two Loopback Control Planes Fall to a Page the Operator Visited

Two CVEs landed within five minutes of each other late on 16 October 2026, both from security-advisories@github.com, both against agent software that binds a port on 127.0.0.1 and assumes that is a boundary. CVE-2026-49410 (22:17:23.490Z, Received, CVSS:3.1 8.1, AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N, CWE-352 and CWE-384) affects OpenHuman below 0.56.0. CVE-2026-108372 (22:17:14.457Z, Received, CVSS:4.0 7.1, AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N, CWE-346 and CWE-942) affects LoopX from 0.0.1 until 0.4.5.

One is a write and one is a read. In the first, a page in your browser silently switches your local agent’s identity to the attacker’s cloud account. In the second, a page in your browser reads your agent control plane’s state and the contents of your private design notes. Neither needs network position, a port scan, or any prior compromise. Both need you to visit a web page — which, for the population running an always-on local agent, is a description of a Tuesday.

There is a third thread tying them together, and it is the one we think matters most: both were found by the same automated security reviewer, and both advisories are better security writing than most human reports we read. More on that below, because it cuts against a story this site covered two days ago.

OpenHuman: the attacker logs you in

OpenHuman — 42,116 stars, Rust, GPL-3.0, created 18 February 2026, described by its own repository as “the fastest, cheapest, most efficient open-source agent harness” — ships a Tauri desktop app whose core binds an HTTP server to 127.0.0.1:7788. Among its routes is GET /auth/telegram?token=…, the landing point for a Telegram bot login link. The handler consumes the one-time token against the backend, receives a JWT, and stores it as the machine’s active local session through credentials::ops::store_session.

That route is unauthenticated, and for a defensible reason the advisory states plainly: “browsers cannot attach the local Authorization header when the user clicks a magic link from Telegram.” We verified the exemption first-hand in src/core/auth.rs, where /auth/telegram sits in a PUBLIC_PATHS list alongside /, /health, /auth, /schema, /events, and /ws/dictation. The comment above that list sets out the project’s reasoning verbatim: “/rpc and /v1/* carry executable surfaces and must be protected. All other routes are read-only, streaming, or WebSocket upgrades whose clients (browser EventSource, browser WebSocket) cannot set Authorization headers via standard APIs.”

The flaw is in that sentence, not in the code under it. /auth/telegram is not read-only. It writes the session. The taxonomy that justified the exemption had two categories — executable surfaces and everything else — and the route that sets who you are fell into “everything else.”

The attack is the simplest one in the book. The advisory gives the shape: any page the victim loads can emit <img src="http://127.0.0.1:7788/auth/telegram?token=<ATTACKER_TOKEN>">, or a fetch(), or an <iframe>. The local core consumes the attacker’s own one-time login token and binds the victim’s machine to the attacker’s backend session. The port is fixed at 7788 by default, so there is no discovery step.

The impact section is worth quoting, because this is session implantation rather than session theft and the direction confuses people: after exploitation “all subsequent agent actions, memory writes, and Telegram messages the user issues are scoped to the attacker’s account on the cloud side,” and the attacker, signing into that same account from their own machine, “observes those actions in (near-)real time.” Local user-scoped data is re-rooted: writes that belonged to the legitimate user are filed under the attacker’s user_id. Nothing is stolen. Everything the agent does from that moment is simply done into the attacker’s account, including whatever the operator pastes into it next.

The advisory then pre-empts each control that looks like it should have caught this, and the list is a good mental model for anyone shipping a loopback service:

  • The bearer token protects /rpc only; the public paths bypass rpc_auth_middleware entirely.
  • CORS is irrelevant here, and the reasoning is the part people get wrong: Access-Control-Allow-Origin: * was being sent unconditionally, but even a correct CORS policy would not help, because “browsers send <img>/<iframe> requests as simple-request GETs without consulting CORS preflight, so the side effect lands regardless.” CORS governs who may read a response. It has never governed who may cause one.
  • The loopback bind stops off-network attackers and does nothing against a page running inside the victim’s browser, which reaches loopback by design.
  • The one-time token invalidates after use but “does not gate which machine consumes it” — single-use is a replay control, not an authorisation control.

The shipped fix, read first-hand in src/core/jsonrpc.rs at tag v0.56.0, adds telegram_callback_origin_ok() in front of the handler body. It requires Sec-Fetch-Mode: navigate and Sec-Fetch-Dest: document when those headers are present, rejects Sec-Fetch-Site: cross-site unless the Referer or Origin is https://t.me or https://web.telegram.org, and falls back to a parsed-host Referer check when Sec-Fetch-Site is absent — parsed with a real URL parser specifically, the comment notes, so that http://localhost.attacker.example/… does not satisfy the gate. Rejection returns 403 with user-facing text telling the operator to open the bot’s link directly rather than let another page redirect them there.

One honest divergence from the report: the reporter proposed requiring Sec-Fetch-Dest: document and Sec-Fetch-Mode: navigate, plus a Host header check against DNS rebinding. The shipped version makes the Sec-Fetch-* checks conditional on those headers being present (documented as accepting “older browsers, CLI clients”) and does not add the Host check. Against the stated threat — a page in a modern browser, all of which send Sec-Fetch-* unconditionally — this closes the vector. It is a deliberate compatibility trade, recorded here because the difference between the proposed and shipped guard is exactly the kind of detail that gets lost between an advisory and a changelog.

A note on versions, because it is unusual: the fix is commit b1bbc53f (21 May 2026, 1,646 additions across a broad “tighten runtime policy + transport guards” PR), v0.56.0 was published 27 May, the GHSA on 9 October, and the CVE on 16 October — roughly five months from fix to CVE, with the security change delivered inside a general hardening release rather than as a standalone security fix.

LoopX: the wildcard on the read path

LoopX — 6,344 stars, Python, Apache-2.0 — is a local-first control plane whose loopx serve-status command runs an HTTP status server on hardcoded default ports 8765 (project) and 8766 (global). The vulnerable line, read first-hand at the fix commit, is one header in StatusRequestHandler._send_json():

self.send_header("Access-Control-Allow-Origin", "*")

_send_json is the single response path for every route, so the wildcard applied to /status.json, /review-material, /healthz, /, and the dry-run POSTs alike. Any origin could therefore read:

  • GET /status.json — goal ids, run history, and local absolute paths. With --global-registry, this covers every project registered on the machine.
  • GET /review-material?goal_id=…&path=… — the content of any .md/.markdown file under a goal’s repo, state, or runtime root. In practice: private design notes, handoff docs, evidence write-ups.
  • GET / — endpoint discovery, including which write APIs are enabled.
  • POST /control-plane/configure-goal/dry-run — no Origin check at all, and do_OPTIONS returned Access-Control-Allow-Headers: Content-Type, so a cross-origin JSON POST passed preflight and leaked the before/after control-plane configuration.

Two details raise this above a theoretical CORS finding, and both are quoted from the advisory. First, the ports are hardcoded defaults, “so an attacker page can simply try both — no discovery step needed.” Second, and more consequentially, the project’s own scripts/macos-dashboard-launchagent.sh installs a LaunchAgent that keeps serve-status --global-registry --port 8766 running “after every login.” For anyone who followed the documented getting-started path, this was “an always-on, machine-wide read surface.” The advisory also points at the project’s docs/public-private-boundary.md, which explicitly classifies local absolute paths, task ids, raw logs and metrics, and active goal state as material that must stay private — so the exposure is measured against the project’s own written policy rather than an outside standard.

The fix, commit 838720fb (12 August 2026, 16:01:53Z, 108 added / 6 deleted across two files, 85 of those lines a new tests/test_status_server_cors.py), replaces the wildcard with a cors_response_headers() helper that echoes the origin only when the project’s existing is_loopback_origin() predicate accepts it, adds Vary: Origin, and returns no CORS headers at all when there is no Origin header. The commit message states the invariant exactly: “Only loopback browser origins may read responses cross-origin now; foreign origins and non-browser clients receive no ACAO header.” The v0.4.5 release (12 August, 16:26:36Z, twenty-five minutes later) describes itself as “a security-hardening and control-plane release” fixing five privately reported advisories.

The predicate the fix uses already existed in the same file. It was being applied to the write endpoints and not to the read ones — which is the same failure mode as Renovate’s preset CVEs, where a policy enforced on repository configuration was skipped on configuration that arrived through extends. The control was not missing. It was applied at one of the two places it needed to be.

The reports are the story

Both advisories credit Aeon (aeonframework), an automated security review agent, with aaronjmars listed as reporter on the OpenHuman record. We have no opinion on the product and did not run it. We do have an opinion on the artefacts, because they are unusually good, and the specific thing that makes them good is rare in human reports too.

The LoopX advisory contains a section headed “What is NOT affected (I checked, so you don’t have to).” It records that the write endpoints return 403 for foreign origins and that DNS rebinding does not defeat them; that ../../../../etc/hostname returns 400 and path_is_under() is correct; that the extension allowlist blocks secrets.env; and that the dry-run genuinely does not write (written=false, observed). It states the boundary of its own finding in plain language: “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.” It discloses scope and coverage honestly — 26 of 52 identified entrypoints across 758 files, tool versions named, tests/ and examples/ skipped, “coverage confidence: partial” — and it lists things it examined and cleared, including a shell=True worker tick it declined to report because the value is a user-configured command passed through shlex.quote. On severity it writes: “if you read the exposure as lower I would not argue the point — you know your users’ setups better than I do.”

The OpenHuman advisory does the same work in a different register: it omits the payload per responsible-disclosure norms while giving a curl command that demonstrates the surface without running the attack, enumerates four existing controls and why each fails, arrives with a patch branch and 17 unit tests pinning behaviour across <img>, <iframe>, <script>, cross-origin fetch(), missing Sec-Fetch-* headers, rebinding hosts, evil.localhost, and both IPv6 loopback spellings — and files three out-of-scope observations separately rather than inflating the one report.

Set that against the record we briefed on 16 October, where an SGLang maintainer closed and blocked an account that had opened 37 PRs in two days — near-duplicates, resubmissions, and runtime changes with “no test repro or numbers” — one of which happened to patch a 9.2. That policy was defensible, and it was also a blunt instrument applied to a submitter rather than a submission. These two advisories are the other side of the same question. Automation is now producing both the flood and the best-documented reports in the queue, and no volume heuristic distinguishes them. What distinguishes them is reproduction, scope honesty, and a refutation section — properties a reviewer can check in a minute, and properties neither of these reports would pass if they were padding.

The same account is also working the discoverability problem this site has tracked all month. Brave discovery surfaced a cluster of GitHub issues in which aeonframework asks maintainers to click “Request CVE” on already-published advisories — we read one first-hand, opened 9 October against another project, with the reasoning stated correctly: “GitHub then assigns one, and scanners like Dependabot and OSV flag the affected versions so users know to upgrade.” That is precisely the mechanism, and these two records show why it is not sufficient on its own.

Neither of these is in your scanner

Measured directly at time of writing, and worth contrasting with the Claude Agent SDK record published the same evening, which is properly indexed:

  • GET api.github.com/advisories/GHSA-c4m9-hw7m-vv3m (OpenHuman) → 404. OSV: 404 for both the GHSA and CVE-2026-49410.
  • GET api.github.com/advisories/GHSA-vx2m-gpq4-8j5q (LoopX) → 404. OSV: 404 for the GHSA and for CVE-2026-108372; an OSV version query for loopx 0.4.4 on PyPI returns an empty list.

For OpenHuman the mechanism is visible in the advisory’s own affected block: ecosystem other, package name openhuman. That is not a coordinate any scanner resolves — not crates.io, not npm, not an OCI reference — the same structural gap we recorded for OPNsense and for the destructive-command-guard binary. LoopX is harder to excuse: it is on PyPI, the advisory records pip as the ecosystem, and the record still does not resolve. Both now have CVE ids, which is the step Aeon was pushing maintainers toward. Neither has reached the feed your tooling actually queries.

Related reading: Archon’s unauthenticated agent control plane for the version of this where the bind address itself is the bug, and the QwenPaw dispute for what a loopback default buys you and what it does not.

What to do

Upgrade: OpenHuman to 0.56.0 or later, LoopX to 0.4.5 or later. Neither has a configuration workaround; the controls are in request handling.

If you installed the LoopX dashboard LaunchAgent, check it before you check the version. scripts/macos-dashboard-launchagent.sh keeps serve-status --global-registry alive across every login, so a machine that followed the quickstart has been serving this since install. Exposure is read-only and leaves no crash — the artefact would be in outbound requests from a browser, not in the server’s logs — so treat Markdown under registered goal roots as potentially disclosed rather than assuming you would have noticed. Rotate anything that ended up in a design note, which in the advisory’s own proof-of-concept was a PROD_DB_HOST line.

For OpenHuman, re-verify which account the local session belongs to. The failure mode is silent and the direction is counter-intuitive: nothing is stolen, so there is no missing-data signal. Confirm the active session maps to your own backend identity, review agent actions and memory writes from the exposure window for work that was filed somewhere unexpected, and be aware that active_user.toml and users/<uid>/ paths are the local evidence.

Then apply the general rule to every loopback service you run. A port on 127.0.0.1 is not an authorisation boundary; it is a network-reachability boundary, and the browser on the same machine is inside it. The two practical consequences: never put a state-changing route on an unauthenticated allowlist because its clients cannot send headers — validate fetch metadata instead, which is what the OpenHuman fix does; and never emit Access-Control-Allow-Origin: * from a response path shared by authenticated and unauthenticated routes, because the wildcard inherits every route that helper serves. Both projects already owned the right predicate. The bug, in each case, was the set of responses it was applied to.

Sourcing: both CVE ids, publication timestamps, vulnStatus values, source identifiers, CVSS scores and vectors, CWE lists, affected ranges, and reference lists read first-hand from the NVD 2.0 API. Both GHSA advisories — summary, severity, publication date, affected package and ecosystem, structured credits, and every quoted sentence — read first-hand from the GitHub repository security-advisory API. OpenHuman PUBLIC_PATHS, its explanatory comment, and the full telegram_callback_origin_ok() guard read first-hand from src/core/auth.rs and src/core/jsonrpc.rs at tag v0.56.0; fix commit b1bbc53f author, timestamp, message, file list and diff statistics, plus release name and publication timestamp, from the GitHub commit and release APIs. LoopX fix commit 838720fb message and complete diff, the post-fix _send_json at tag v0.4.5, and the v0.4.5 release text read first-hand from the GitHub APIs and raw file contents. Repository metadata (OpenHuman 42,116 stars / 4,154 forks / Rust / GPL-3.0 / created 18 February 2026; LoopX 6,344 stars / 603 forks / Python / Apache-2.0 / created 31 May 2026) from the GitHub repository API. The Aeon CVE-request issue was discovered via Brave Search and its title, author, date, state, and body read first-hand from the GitHub issues API. GitHub global-advisory and OSV lookups, including the LoopX PyPI version query, measured directly at time of writing. Brave Search used for discovery only; it returned CVE mirrors and automated scoring pages with no independent technical analysis of either record. Nothing installed, no OpenHuman or LoopX instance run, no login token generated or consumed, no cross-origin page authored, no request sent to any loopback service, no payload executed, and no third-party request sent beyond reading the cited public records. All reproduction output, status codes, and file contents quoted from the advisories are the reporter’s measurements, not ours.