Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/14381-relay-cli-config-security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **CLI configuration security:** redact generated previews and existing profile credentials, reject credentials in preview URLs, validate requests, and use private atomic configuration/backup writes without weakening the container write guard. ([#14381](https://github.com/diegosouzapw/OmniRoute/pull/14381))
5 changes: 0 additions & 5 deletions config/quality/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -1356,11 +1356,6 @@
"count": 1
}
},
"src/lib/cli-helper/config-generator/index.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 2
}
},
"src/lib/cli-helper/config-generator/kilocode.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
Expand Down
29 changes: 29 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8796,6 +8796,11 @@ paths:
tags:
- Cli tools
summary: "POST cli tools › apply"
description: >-
Submit the original toolId, apiKey, optional baseUrl/model and optional
dryRun in the JSON body. Returned content is a redacted, non-cacheable
preview, not an importable configuration. A non-dry-run request writes
the original generated configuration; the container write guard remains active.
responses:
"200":
description: OK
Expand All @@ -8804,13 +8809,37 @@ paths:
tags:
- Cli tools
summary: "GET cli tools › config"
description: >-
Returns redacted, non-cacheable previews. Send the configuration API key
in x-omniroute-config-api-key, separate from management authentication.
API keys in query strings are rejected. Preview content must not be
copied into a credential-bearing configuration or submitted as an apply payload.
parameters:
- in: header
name: x-omniroute-config-api-key
required: true
schema:
type: string
minLength: 1
- in: query
name: baseUrl
required: false
schema:
type: string
format: uri
description: Absolute HTTP(S) URL without embedded username/password credentials.
responses:
"200":
description: OK
post:
tags:
- Cli tools
summary: "POST cli tools › config"
description: >-
Submit toolId, apiKey and optional baseUrl/model as JSON. Unknown fields
are rejected. Returned content is a redacted, non-cacheable preview,
not an importable configuration. To apply, submit the original inputs
to POST /api/cli-tools/apply instead of replaying the preview content.
responses:
"200":
description: OK
Expand Down
7 changes: 5 additions & 2 deletions docs/reference/CLI-TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,11 @@ host answers **`422`** with `containerEphemeralTarget: true`, the safe error
text and — for the tools with a host recipe (claude, codex, opencode, cline,
kilo, continue) — a `hostSetupCommand` (e.g. `omniroute setup-opencode`) to run
on the host instead; nothing is written. `dryRun: true` keeps working in container
mode and returns the generated content + target path without touching disk, so
you can preview from the dashboard and apply on the host. This behavior is
mode and returns a redacted preview + target path without touching disk. Preview
content is not a credential-bearing configuration to copy or import. Apply with
the original tool/base URL/API key/model inputs on the host, or use the indicated
host-side setup command. See [CLI configuration security](../security/CLI-CONFIGURATION.md)
for the preview header and request contract. This behavior is
intentional and regression-guarded by
`tests/unit/api/cli-tools/apply-container-guard.test.ts` — never "fix" a 422
by removing the guard.
Expand Down
37 changes: 37 additions & 0 deletions docs/security/CLI-CONFIGURATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
title: "CLI configuration previews and writes"
---

# CLI configuration previews and writes

The authenticated `/api/cli-tools/config` and `/api/cli-tools/apply` routes return
non-cacheable, redacted previews. They preserve the existing authentication and
authorization checks. Request bodies reject unknown fields. An explicit base URL
must use HTTP(S) without embedded username/password credentials; when omitted,
the server's configured base URL is used.

For a batch preview, `GET /api/cli-tools/config` accepts an optional `baseUrl` query
parameter and takes the configuration credential from the
`x-omniroute-config-api-key` header. Credentials in the query string are rejected.
`POST /api/cli-tools/config` accepts `toolId`, `baseUrl`, `apiKey` and optional
`model` in its JSON body. Responses use `Cache-Control: no-store` on both success
and error paths. Preview text is not a usable credential-bearing configuration.

Preview projection redacts serialized caller credentials and credential fields
already present in merged JSONC, TOML or YAML profiles. The apply route writes the
original configuration; preview redaction does not alter the on-disk values.
Malformed JSON is rejected with 400. Public error responses use the standard
sanitized error envelope.

Applying a configuration uses a private temporary file in the destination
directory, flushes it, and atomically renames it into place. Files and backups use
mode `0600`; permissions are set on the owned descriptor before rename. Final
destination symlinks and non-regular files are refused. Codex and OpenCode reads
also refuse final-path symlinks before merging existing TOML/JSONC. This does not
claim protection against an adversary who controls the parent directory.

The container write guard remains active: an unsafe ephemeral destination returns
422 and directs the operator to the host-side setup command. A dry run remains
available and does not write files. See [CLI integrations](../guides/CLI-INTEGRATIONS.md)
for the supported host-side setup commands and
[error sanitization](./ERROR_SANITIZATION.md) for the public error contract.
13 changes: 10 additions & 3 deletions skills/omni-cli-tools/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,31 +398,38 @@ curl https://localhost:20128/api/cli-tools/all-statuses \

POST cli tools › apply

Submit the original toolId, apiKey, optional baseUrl/model and optional dryRun in the JSON body. Returned content is a redacted, non-cacheable preview, not an importable configuration. A non-dry-run request writes the original generated configuration; the container write guard remains active.

```bash
curl -X POST https://localhost:20128/api/cli-tools/apply \
-H "Authorization: Bearer $OMNIROUTE_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
-d '{"toolId":"claude","apiKey":"<configuration-api-key>","dryRun":true}'
```

### GET /api/cli-tools/config

GET cli tools › config

Returns redacted, non-cacheable previews. Send the configuration API key in x-omniroute-config-api-key, separate from management authentication. API keys in query strings are rejected. Preview content must not be copied into a credential-bearing configuration or submitted as an apply payload.

```bash
curl https://localhost:20128/api/cli-tools/config \
-H "Authorization: Bearer $OMNIROUTE_TOKEN"
-H "Authorization: Bearer $OMNIROUTE_TOKEN" \
-H "x-omniroute-config-api-key: <configuration-api-key>"
```

### POST /api/cli-tools/config

POST cli tools › config

Submit toolId, apiKey and optional baseUrl/model as JSON. Unknown fields are rejected. Returned content is a redacted, non-cacheable preview, not an importable configuration. To apply, submit the original inputs to POST /api/cli-tools/apply instead of replaying the preview content.

```bash
curl -X POST https://localhost:20128/api/cli-tools/config \
-H "Authorization: Bearer $OMNIROUTE_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
-d '{"toolId":"claude","apiKey":"<configuration-api-key>"}'
```

### GET /api/cli-tools/deepseek-tui-settings
Expand Down
76 changes: 41 additions & 35 deletions src/app/api/cli-tools/apply/route.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth";
import fs from "node:fs";
import path from "node:path";
import { generateConfig } from "@/lib/cli-helper/config-generator";
import { generateConfig, redactGeneratedConfig } from "@/lib/cli-helper/config-generator";
import { readPrivateConfigFile, writePrivateConfigFile } from "@/lib/cli-helper/privateConfigFile";
import { guardCliConfigWrite } from "@/lib/api/cliConfigWriteGuard";
import { getCliPrimaryConfigPath, normalizeCliToolId } from "@/shared/services/cliRuntime";
import {
configRequestSchema,
configError,
defaultConfigBaseUrl,
privateConfigResponse,
} from "@/lib/cli-helper/configRequest";

const applySchema = z.object({
toolId: z.string().min(1),
baseUrl: z.string().optional(),
apiKey: z.string().min(1),
model: z.string().optional(),
const applySchema = configRequestSchema.extend({
dryRun: z.boolean().optional(),
});

Expand All @@ -30,54 +32,59 @@ function ensureBackup(configPath: string): string | null {
const backupDir = path.join(path.dirname(configPath), ".omniroute.bak");
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
const backupPath = path.join(backupDir, path.basename(configPath) + ".bak");
fs.copyFileSync(configPath, backupPath);
writePrivateConfigFile(backupPath, readPrivateConfigFile(configPath));
return backupPath;
}

// POST /api/cli-tools/apply - Apply config for a specific tool
export async function POST(request: Request) {
const authError = await requireCliToolsAuth(request);
if (authError) return authError;
if (authError) {
authError.headers.set("cache-control", "no-store");
return authError;
}

let body: unknown;
try {
const parsed = applySchema.safeParse(await request.json());
body = await request.json();
} catch {
return configError(400, "Invalid JSON request");
}

try {
const parsed = applySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
{ status: 400 }
);
return configError(400, "Invalid config request");
}
const { toolId, baseUrl, apiKey, model, dryRun } = parsed.data;
const canonicalToolId = normalizeCliToolId(toolId);

const defaultPort = process.env.API_PORT || process.env.PORT || 20128;
const defaultBaseUrl =
process.env.OMNIROUTE_BASE_URL ||
process.env.BASE_URL ||
`http://localhost:${defaultPort}/v1`;

const result = await generateConfig(canonicalToolId, {
baseUrl: baseUrl || defaultBaseUrl,
baseUrl: baseUrl || defaultConfigBaseUrl(),
apiKey,
model,
});

if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 });
return configError(
400,
redactGeneratedConfig(result.error || "Config generation failed", [apiKey])
);
}
const safeContent = redactGeneratedConfig(result.content || "", [apiKey]);

if (dryRun) {
return NextResponse.json({
return privateConfigResponse({
dryRun: true,
configPath: result.configPath,
content: result.content,
content: safeContent,
...(result.migration ? { migration: result.migration } : {}),
});
}

const configPath = result.configPath || getCliPrimaryConfigPath(canonicalToolId);
if (!configPath) {
return NextResponse.json({ error: `Unknown tool: ${toolId}` }, { status: 400 });
return configError(400, "Unknown CLI tool");
}

// A container write into an unmounted path looks successful and then
Expand All @@ -86,24 +93,23 @@ export async function POST(request: Request) {
toolLabel: canonicalToolId,
hostCommand: HOST_SETUP_COMMANDS[canonicalToolId],
});
if (refusal) return refusal;
if (refusal) {
refusal.headers.set("cache-control", "no-store");
return refusal;
}

const backupPath = ensureBackup(configPath);

const dir = path.dirname(configPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });

fs.writeFileSync(configPath, result.content!, "utf-8");
writePrivateConfigFile(configPath, result.content!);

return NextResponse.json({
return privateConfigResponse({
success: true,
configPath,
backupPath,
content: result.content,
content: safeContent,
...(result.migration ? { migration: result.migration } : {}),
});
} catch (error) {
console.log("Error applying config:", error);
return NextResponse.json({ error: "Failed to apply config" }, { status: 500 });
} catch {
return configError(500, "Failed to apply config");
}
}
Loading
Loading