mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
Compare commits
16 Commits
fix/10954-
...
fix/sec-ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d4fd88ed7 | ||
|
|
2a0d878003 | ||
|
|
7986026e7c | ||
|
|
753dde6c86 | ||
|
|
0c52533e01 | ||
|
|
5d1055f3fc | ||
|
|
b654b73e00 | ||
|
|
3d80529280 | ||
|
|
b42e57f97d | ||
|
|
ef78596876 | ||
|
|
f1019ebf23 | ||
|
|
0ccf434f0c | ||
|
|
be1a3ea778 | ||
|
|
a2d5ef50f4 | ||
|
|
49a4ad31e4 | ||
|
|
64b8ffffc1 |
@@ -4,7 +4,6 @@ import { withRuntime } from "../runtime.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { emit } from "../output.mjs";
|
||||
import { resolveComboModels, collectModel } from "./comboModels.mjs";
|
||||
|
||||
const VALID_STRATEGIES = [
|
||||
"priority",
|
||||
@@ -126,31 +125,10 @@ export function registerCombo(program) {
|
||||
.choices(VALID_STRATEGIES)
|
||||
.default("priority")
|
||||
)
|
||||
.option(
|
||||
"--models <spec>",
|
||||
"Models for the combo: comma-separated provider/model entries, or a JSON array " +
|
||||
'(e.g. --models "openai/gpt-4o,anthropic/claude-3-opus" or ' +
|
||||
'--models \'[{"model":"gpt-4o","providerId":"openai"}]\')'
|
||||
)
|
||||
.option(
|
||||
"--model <spec>",
|
||||
"Add one model to the combo (provider/model or bare model id) — repeatable",
|
||||
collectModel,
|
||||
[]
|
||||
)
|
||||
.action(async (name, opts, cmd) => {
|
||||
const globalOpts = cmd.parent.optsWithGlobals();
|
||||
let models;
|
||||
try {
|
||||
models = resolveComboModels(opts);
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
const exitCode = await runComboCreateCommand(name, opts.strategy, {
|
||||
...opts,
|
||||
models,
|
||||
output: globalOpts.output,
|
||||
});
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
@@ -306,14 +284,12 @@ export async function runComboCreateCommand(name, strategy = "priority", opts =
|
||||
return 1;
|
||||
}
|
||||
|
||||
const models = Array.isArray(opts.models) ? opts.models : [];
|
||||
|
||||
try {
|
||||
return await withRuntime(async ({ kind, api, db }) => {
|
||||
if (kind === "http") {
|
||||
const res = await api("/api/combos", {
|
||||
method: "POST",
|
||||
body: { name, strategy, enabled: true, models, config: {} },
|
||||
body: { name, strategy, enabled: true, models: [], config: {} },
|
||||
retry: false,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
@@ -329,7 +305,7 @@ export async function runComboCreateCommand(name, strategy = "priority", opts =
|
||||
console.error(`Combo '${name}' already exists. Delete it first.`);
|
||||
return 1;
|
||||
}
|
||||
await db.combos.createCombo({ name, strategy, enabled: true, models, config: {} });
|
||||
await db.combos.createCombo({ name, strategy, enabled: true, models: [], config: {} });
|
||||
}
|
||||
|
||||
console.log(t("combo.created", { name }));
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
// Parses the `--models` / `--model` options for `omniroute combo create` (#10954).
|
||||
//
|
||||
// Root cause of #10954: `combo create` only ever registered `--strategy`; the
|
||||
// HTTP body (POST /api/combos) and the local-db fallback (db.combos.createCombo)
|
||||
// both hardcoded `models: []`, so every combo created via the CLI came out
|
||||
// empty regardless of what the operator intended to route to.
|
||||
//
|
||||
// Accepted shapes mirror the server-side Zod union in
|
||||
// `src/shared/validation/schemas/combo.ts` (`comboModelEntry` /
|
||||
// `createComboSchema.models`) so a CLI-built payload never gets rejected by
|
||||
// the API that ultimately validates it:
|
||||
// - a plain string ("provider/model" or a bare model id) — the server's
|
||||
// `normalizeComboModels` (src/lib/combos/steps.ts) already splits the
|
||||
// leading "provider/" segment off a plain string, so passing the raw
|
||||
// token through is sufficient for the common case;
|
||||
// - a structured `{ kind?: "model", model, providerId?, provider?, ... }`
|
||||
// object;
|
||||
// - a structured `{ kind: "combo-ref", comboName, ... }` object (nested
|
||||
// combo reference).
|
||||
//
|
||||
// The CLI (bin/cli/**) ships as plain `.mjs` with relative-only imports — no
|
||||
// `@/` path aliases and no TS transpilation at runtime — so importing the
|
||||
// real Zod schema from `src/shared/validation/schemas/combo.ts` is not
|
||||
// viable here. This module instead validates the same minimal shape by hand
|
||||
// and stays a thin, independently testable unit.
|
||||
|
||||
/**
|
||||
* Validates one already-parsed combo model entry against the shape accepted
|
||||
* by `comboModelEntry` (string | model-step | combo-ref). Throws with a
|
||||
* 1-based, human-readable position when the entry does not match.
|
||||
*
|
||||
* @param {unknown} entry
|
||||
* @param {number} index
|
||||
* @returns {string | Record<string, unknown>}
|
||||
*/
|
||||
export function validateComboModelEntryShape(entry, index) {
|
||||
const position = index + 1;
|
||||
|
||||
if (typeof entry === "string") {
|
||||
const trimmed = entry.trim();
|
||||
if (trimmed.length === 0) {
|
||||
throw new Error(`--models entry #${position}: empty model string`);
|
||||
}
|
||||
if (trimmed.length > 300) {
|
||||
throw new Error(`--models entry #${position}: model string exceeds 300 characters`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
throw new Error(`--models entry #${position}: must be a string or a JSON object`);
|
||||
}
|
||||
|
||||
const kind = entry.kind;
|
||||
|
||||
if (kind === "combo-ref") {
|
||||
if (typeof entry.comboName !== "string" || entry.comboName.trim().length === 0) {
|
||||
throw new Error(
|
||||
`--models entry #${position}: kind "combo-ref" requires a non-empty "comboName"`
|
||||
);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
if (kind !== undefined && kind !== "model") {
|
||||
throw new Error(`--models entry #${position}: unknown "kind" value ${JSON.stringify(kind)}`);
|
||||
}
|
||||
|
||||
if (typeof entry.model !== "string" || entry.model.trim().length === 0) {
|
||||
throw new Error(`--models entry #${position}: requires a non-empty "model"`);
|
||||
}
|
||||
if (entry.providerId !== undefined && typeof entry.providerId !== "string") {
|
||||
throw new Error(`--models entry #${position}: "providerId" must be a string`);
|
||||
}
|
||||
if (entry.provider !== undefined && typeof entry.provider !== "string") {
|
||||
throw new Error(`--models entry #${position}: "provider" must be a string`);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses one `--models` spec — either a JSON array (`--models '[{"model":"gpt-4o"}]'`)
|
||||
* or a comma-separated list of provider/model tokens
|
||||
* (`--models 'openai/gpt-4o,anthropic/claude-3-opus'`) — into an array of
|
||||
* combo model entries.
|
||||
*
|
||||
* @param {string} spec
|
||||
* @returns {Array<string | Record<string, unknown>>}
|
||||
*/
|
||||
export function parseModelsSpec(spec) {
|
||||
const trimmed = String(spec ?? "").trim();
|
||||
if (trimmed.length === 0) return [];
|
||||
|
||||
if (trimmed.startsWith("[")) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch (err) {
|
||||
throw new Error(`--models: invalid JSON array (${err.message})`);
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("--models: JSON value must be an array");
|
||||
}
|
||||
return parsed.map((entry, i) => validateComboModelEntryShape(entry, i));
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.split(",")
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length > 0)
|
||||
.map((token, i) => validateComboModelEntryShape(token, i));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the final `models` array for `combo create` from Commander opts:
|
||||
* `--models <csv-or-json>` and/or repeatable `--model <spec>`.
|
||||
*
|
||||
* @param {{ models?: string, model?: string[] }} opts
|
||||
* @returns {Array<string | Record<string, unknown>>}
|
||||
*/
|
||||
export function resolveComboModels(opts = {}) {
|
||||
const result = [];
|
||||
|
||||
if (typeof opts.models === "string" && opts.models.trim().length > 0) {
|
||||
result.push(...parseModelsSpec(opts.models));
|
||||
}
|
||||
|
||||
if (Array.isArray(opts.model)) {
|
||||
opts.model.forEach((token, i) => {
|
||||
result.push(validateComboModelEntryShape(String(token).trim(), i));
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Commander `collect`-style reducer for the repeatable `--model` option. */
|
||||
export function collectModel(value, previous) {
|
||||
previous.push(value);
|
||||
return previous;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
- fix(cli): combo create accepts --models and no longer creates empty combos (#10954)
|
||||
@@ -104,6 +104,12 @@ import {
|
||||
import { applyPeerTraceHeader } from "@/shared/resilience/peerRouting";
|
||||
import { applyClineProtocolHeaders } from "@/shared/utils/clineAuth";
|
||||
import { isProbeContext } from "@/shared/utils/probeOrigin";
|
||||
import {
|
||||
parseAndValidatePublicUrl,
|
||||
parseAndValidateNonMetadataUrl,
|
||||
} from "@/shared/network/outboundUrlGuard";
|
||||
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
import { isLocalProvider, isSelfHostedChatProvider } from "@/shared/constants/providers";
|
||||
// Header helpers extracted to a pure leaf; re-exported for external importers
|
||||
// (executors + tests) that import them from "./base.ts".
|
||||
export {
|
||||
@@ -397,6 +403,29 @@ export class BaseExecutor {
|
||||
return fallback || this.config.baseUrl || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* SSRF guard for the runtime dispatch path (GHSA-4f49-hj64-448x). A persisted,
|
||||
* caller-supplied `providerSpecificData.baseUrl` reaches the fetch() calls
|
||||
* below, so a `manage`-scope actor (or, on a keyless install, an anonymous
|
||||
* one) could point a provider at loopback / internal / cloud-metadata hosts
|
||||
* and exfiltrate the stored upstream key. Mirror the provider VALIDATION
|
||||
* guard so runtime dispatch makes the same decision the validation layer
|
||||
* already makes: local / self-hosted providers are exempt (they legitimately
|
||||
* use private URLs, and the OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS opt-in still
|
||||
* applies through the guard), and for everything else `public-only` mode
|
||||
* blocks private + metadata while the default `block-metadata` mode blocks the
|
||||
* cloud-metadata IMDS pivot. Throws on a blocked URL.
|
||||
*/
|
||||
protected assertOutboundUrlAllowed(url: string): void {
|
||||
if (!url) return;
|
||||
if (isLocalProvider(this.provider) || isSelfHostedChatProvider(this.provider)) return;
|
||||
if (getProviderValidationGuard() === "public-only") {
|
||||
parseAndValidatePublicUrl(url);
|
||||
return;
|
||||
}
|
||||
parseAndValidateNonMetadataUrl(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternate protocol selected on this connection, if the provider declares one
|
||||
* that matches. Centralizes the registry lookup so every call-site resolves the
|
||||
@@ -615,6 +644,7 @@ export class BaseExecutor {
|
||||
async countTokens({ model, body, credentials, signal, log }: CountTokensInput) {
|
||||
const url = this.buildCountTokensUrl(model, credentials);
|
||||
if (!url) return null;
|
||||
this.assertOutboundUrlAllowed(url); // GHSA-4f49
|
||||
|
||||
const headers = this.buildHeaders(credentials, false);
|
||||
const requestBody =
|
||||
@@ -869,6 +899,9 @@ export class BaseExecutor {
|
||||
// Timeout only covers response start; stream stalls are handled downstream.
|
||||
const fetchStartTimeoutMs = this.getTimeoutMs();
|
||||
const fetchWithStartTimeout = async (requestUrl: string, requestOptions: RequestInit) => {
|
||||
// GHSA-4f49: guard here (not only next to the first buildUrl) so retries
|
||||
// and fallback URLs are validated too, before any bytes leave the host.
|
||||
this.assertOutboundUrlAllowed(requestUrl);
|
||||
const timeoutController = fetchStartTimeoutMs > 0 ? new AbortController() : null;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
if (timeoutController) {
|
||||
|
||||
@@ -430,6 +430,7 @@ export class GlmExecutor extends DefaultExecutor {
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
this.assertOutboundUrlAllowed(url); // GHSA-4f49: glm has its own fetch path
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
|
||||
@@ -471,6 +471,7 @@ export class NlpCloudExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
try {
|
||||
this.assertOutboundUrlAllowed(url); // GHSA-4f49: nlpcloud has its own fetch path
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
|
||||
@@ -10,16 +10,24 @@ import {
|
||||
import { resolveMcpCallerApiKeyId } from "../mcpCallerIdentity.ts";
|
||||
|
||||
/**
|
||||
* Resolve the memory owner id for an MCP tool call:
|
||||
* explicit arg wins, otherwise fall back to the authenticated caller's
|
||||
* principal id (HTTP auth headers on SSE/Streamable HTTP transports,
|
||||
* OMNIROUTE_API_KEY env var on stdio). Keeps MCP-stored memories under
|
||||
* the same owner id that chat-context memory uses, so retrieval in the
|
||||
* chat pipeline finds entries written via MCP.
|
||||
* Resolve the memory owner id for an MCP tool call.
|
||||
*
|
||||
* The authenticated caller's principal ALWAYS wins over a caller-supplied
|
||||
* `apiKeyId` — otherwise any MCP caller could read, write, or delete another
|
||||
* principal's memories by putting a different id in the tool arguments
|
||||
* (GHSA-cpv3-xr7r-xf8q, IDOR). The caller is resolved from the per-request HTTP
|
||||
* auth headers on SSE / Streamable HTTP transports, or from OMNIROUTE_API_KEY on
|
||||
* stdio. The explicit argument is only honored as a fallback when no caller can
|
||||
* be resolved (a bare local stdio process with no configured key — already
|
||||
* trusted), preserving the local-tooling flow. Keeps MCP-stored memories under
|
||||
* the same owner id that chat-context memory uses, so retrieval in the chat
|
||||
* pipeline finds entries written via MCP.
|
||||
*/
|
||||
async function resolveMemoryOwnerId(explicit?: string): Promise<string> {
|
||||
const caller = await resolveMcpCallerApiKeyId().catch(() => undefined);
|
||||
if (caller) return caller;
|
||||
if (explicit && explicit.trim() !== "") return explicit.trim();
|
||||
return (await resolveMcpCallerApiKeyId().catch(() => undefined)) || "mcp";
|
||||
return "mcp";
|
||||
}
|
||||
|
||||
export const MemorySearchSchema = z.object({
|
||||
|
||||
@@ -70,11 +70,6 @@ omniroute combo switch <name>
|
||||
|
||||
Create a new routing combo
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `--models <spec>`
|
||||
- `--model <spec>`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
|
||||
@@ -17,6 +17,8 @@ import { logRoutingDecision } from "@/lib/a2a/routingLogger";
|
||||
import { createA2AStream, SSE_HEADERS } from "@/lib/a2a/streaming";
|
||||
import { A2A_SKILL_HANDLERS, executeA2ATaskWithState } from "@/lib/a2a/taskExecution";
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
|
||||
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
|
||||
// ============ A2A v1.0 ↔ v0.3 compatibility layer ============
|
||||
// A2A 1.0 renamed the JSON-RPC methods (message/send → SendMessage,
|
||||
@@ -136,14 +138,25 @@ function tokensMatch(provided: string, expected: string): boolean {
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function authenticate(req: NextRequest): boolean {
|
||||
// If no API key is configured, allow all requests
|
||||
const configuredKey = process.env.OMNIROUTE_API_KEY;
|
||||
if (!configuredKey) return true;
|
||||
async function authenticate(req: NextRequest): Promise<boolean> {
|
||||
// /a2a is outside the authz proxy matcher, so the REQUIRE_API_KEY posture the
|
||||
// pipeline enforces for /v1 never ran here — the route accepted every caller
|
||||
// whenever OMNIROUTE_API_KEY was unset, which is the shipped default
|
||||
// (GHSA-v54m-6rm3-p565). Apply the same posture directly: when a client key is
|
||||
// required, demand a valid OmniRoute key; otherwise honor the legacy explicit
|
||||
// A2A key; otherwise stay keyless (the same local-first default as /v1).
|
||||
const apiKey = extractApiKey(req);
|
||||
if (isRequireApiKeyEnabled()) {
|
||||
return apiKey ? await isValidApiKey(apiKey) : false;
|
||||
}
|
||||
|
||||
const authHeader = req.headers.get("authorization") || "";
|
||||
const token = authHeader.replace(/^Bearer\s+/i, "");
|
||||
return tokensMatch(token, configuredKey);
|
||||
const configuredKey = process.env.OMNIROUTE_API_KEY;
|
||||
if (configuredKey) {
|
||||
return apiKey ? tokensMatch(apiKey, configuredKey) : false;
|
||||
}
|
||||
|
||||
// No API key required and none configured — allow (keyless local-first).
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============ JSON-RPC Helpers ============
|
||||
@@ -179,7 +192,7 @@ async function rejectIfA2ADisabled(id: string | number | null) {
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Auth check
|
||||
if (!authenticate(req)) {
|
||||
if (!(await authenticate(req))) {
|
||||
return jsonRpcError(null, -32600, "Unauthorized: missing or invalid API key");
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { readRunningBuildSha } from "@/lib/monitoring/buildSha";
|
||||
import { APP_CONFIG } from "@/shared/constants/config";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
/**
|
||||
* GET /api/monitoring/health — System health overview
|
||||
@@ -20,10 +21,25 @@ import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
let healthPayloadCache: { payload: unknown; expiresAt: number } | null = null;
|
||||
const HEALTH_PAYLOAD_TTL_MS = 1000;
|
||||
|
||||
export async function GET() {
|
||||
// GHSA-mvf8-qc78-5mxm: the full health payload fingerprints the host (version,
|
||||
// node version, pid, memory, provider config). An anonymous caller — the common
|
||||
// case on a keyless install, and what a liveness/load-balancer probe needs — gets
|
||||
// only the liveness verdict; the detail is reserved for a management principal.
|
||||
function publicHealthView(payload: unknown): Record<string, unknown> {
|
||||
const p = (payload ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
status: p.status ?? "unknown",
|
||||
...(p.setupComplete !== undefined ? { setupComplete: p.setupComplete } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const fullView = (await requireManagementAuth(request, { alwaysRequireAuth: true })) === null;
|
||||
const cachedNow = Date.now();
|
||||
if (healthPayloadCache && cachedNow <= healthPayloadCache.expiresAt) {
|
||||
return NextResponse.json(healthPayloadCache.payload);
|
||||
return NextResponse.json(
|
||||
fullView ? healthPayloadCache.payload : publicHealthView(healthPayloadCache.payload)
|
||||
);
|
||||
}
|
||||
|
||||
const readHealthValue = <T>(label: string, reader: () => T, fallback: T): T => {
|
||||
@@ -187,7 +203,7 @@ export async function GET() {
|
||||
});
|
||||
|
||||
healthPayloadCache = { payload, expiresAt: Date.now() + HEALTH_PAYLOAD_TTL_MS };
|
||||
return NextResponse.json(payload);
|
||||
return NextResponse.json(fullView ? payload : publicHealthView(payload));
|
||||
} catch (error) {
|
||||
console.error("[API] GET /api/monitoring/health error:", error);
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "@/models";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { isValidGheUrl } from "@/shared/validation/providerSpecificData";
|
||||
import { AWS_REGION_PATTERN } from "@/lib/oauth/constants/oauth";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { startLocalServer } from "@/lib/oauth/utils/server";
|
||||
import { runWithProxyContextOrDirect } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
@@ -221,6 +222,16 @@ export async function GET(
|
||||
(requestDeviceCode as any)(provider, null, providerOverrideConfig)
|
||||
);
|
||||
} else if ((provider === "kiro" || provider === "amazon-q") && startUrl) {
|
||||
// GHSA-7x63: `region` is interpolated into the AWS OIDC endpoint URLs
|
||||
// below, which requestDeviceCode() then fetches. Validate it against the
|
||||
// canonical AWS region shape before it can steer the outbound host to an
|
||||
// attacker-chosen target (userinfo/fragment tricks → SSRF / metadata).
|
||||
if (!AWS_REGION_PATTERN.test(region)) {
|
||||
return NextResponse.json(
|
||||
{ error: "region must be a valid AWS region (e.g. us-east-1)" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const providerOverrideConfig = {
|
||||
...providerData.config,
|
||||
startUrl,
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { createProviderConnection } from "@/models";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import {
|
||||
scanCliProxyAuthDir,
|
||||
@@ -23,9 +23,9 @@ function cliProxyConfigDir(): string {
|
||||
}
|
||||
|
||||
async function requireImportAuth(request: Request) {
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
|
||||
// require management scope (or a dashboard session), not any valid client key.
|
||||
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { extractCodexAccountInfo } from "@/lib/oauth/services/codexImport";
|
||||
import { parseCodexSessionJson } from "@/lib/oauth/utils/codexSessionImport";
|
||||
import { createProviderConnection } from "@/models";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
/**
|
||||
@@ -93,10 +93,11 @@ async function parseRequestBody(
|
||||
return { ok: true, resolved: resolved.resolved };
|
||||
}
|
||||
|
||||
async function requireAuth(request: Request): Promise<NextResponse | null> {
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json(buildErrorBody(401, "Unauthorized"), { status: 401 });
|
||||
async function requireAuth(request: Request): Promise<Response | null> {
|
||||
// GHSA-mg76: importing a provider connection is a state-mutating admin action.
|
||||
// Require management scope (or a dashboard session) rather than accepting any
|
||||
// valid client key, which the PUBLIC /api/oauth/ classification otherwise allows.
|
||||
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { normalizeCodexImportRecord, flattenCodexImportPayload } from "@/lib/oauth/services/codexImport";
|
||||
import { createProviderConnection } from "@/models";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { refreshCodexToken, isUnrecoverableRefreshError } from "@omniroute/open-sse/services/tokenRefresh.ts";
|
||||
|
||||
@@ -82,10 +82,10 @@ const bodySchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
async function requireAuth(request: Request): Promise<NextResponse | null> {
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
async function requireAuth(request: Request): Promise<Response | null> {
|
||||
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
|
||||
// require management scope (or a dashboard session), not any valid client key.
|
||||
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { tryAgentAuth, tryIdeAuth } from "@/lib/cursor/tokenExtractor";
|
||||
|
||||
/**
|
||||
@@ -11,11 +11,9 @@ import { tryAgentAuth, tryIdeAuth } from "@/lib/cursor/tokenExtractor";
|
||||
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-4).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (await isAuthRequired(request)) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
// GHSA-mg76 / GHSA-gxv4: reading/importing host credentials is a management action.
|
||||
const authError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
// Try Cursor IDE first (has both accessToken and machineId)
|
||||
|
||||
@@ -6,15 +6,15 @@ import { isCloudEnabled } from "@/models";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { cursorImportSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
import { resolveProxyForProvider } from "@/models";
|
||||
|
||||
async function requireOAuthImportAuth(request: Request) {
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
|
||||
// require management scope (or a dashboard session), not any valid client key.
|
||||
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import {
|
||||
createProviderConnection,
|
||||
getProviderConnections,
|
||||
@@ -31,11 +31,9 @@ import {
|
||||
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (await isAuthRequired(request)) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
// GHSA-mg76 / GHSA-gxv4: reading/importing host credentials is a management action.
|
||||
const authError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
if (authError) return authError;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const targetProvider = searchParams.get("targetProvider") === "amazon-q" ? "amazon-q" : "kiro";
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { kiroImportSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { findKiroConnectionByIdentity } from "@/lib/oauth/kiroConnectionIdentity";
|
||||
@@ -38,9 +38,9 @@ export function buildKiroImportError(error: unknown): string {
|
||||
}
|
||||
|
||||
async function requireOAuthImportAuth(request: Request) {
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
|
||||
// require management scope (or a dashboard session), not any valid client key.
|
||||
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
}
|
||||
|
||||
async function upsertImportedKiroConnection(
|
||||
|
||||
@@ -14,14 +14,14 @@ import {
|
||||
extractLocalRaycastCredentials,
|
||||
isRaycastLocalExtractAvailable,
|
||||
} from "@/lib/oauth/services/raycastLocal";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { resolveProxyForProvider } from "@/models";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
|
||||
async function requireOAuthImportAuth(request: Request) {
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
|
||||
// require management scope (or a dashboard session), not any valid client key.
|
||||
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
|
||||
@@ -11,14 +11,14 @@ import { createProviderConnection } from "@/models";
|
||||
import { RaycastService } from "@/lib/oauth/services/raycast";
|
||||
import { raycastImportSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { resolveProxyForProvider } from "@/models";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
|
||||
async function requireOAuthImportAuth(request: Request) {
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
|
||||
// require management scope (or a dashboard session), not any valid client key.
|
||||
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { createProviderConnection } from "@/models";
|
||||
import { traeImportSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
/**
|
||||
* POST /api/oauth/trae/import
|
||||
@@ -22,9 +22,9 @@ import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
* region — optional, default "US-East"
|
||||
*/
|
||||
async function requireOAuthImportAuth(request: Request) {
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
|
||||
// require management scope (or a dashboard session), not any valid client key.
|
||||
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
|
||||
import {
|
||||
getObsidianSyncStatus,
|
||||
@@ -21,10 +22,19 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const status = await getObsidianSyncStatus();
|
||||
// GHSA-62vw: the WebDAV password is reusable authentication material. Return
|
||||
// the plaintext only to a genuine management principal (dashboard session or
|
||||
// manage-scope key), never to an anonymous caller that reached this handler
|
||||
// through the requireLogin=false open mode. The dashboard's authenticated
|
||||
// reveal-password view is unaffected; anonymous callers get a set/unset flag.
|
||||
const hasManagement =
|
||||
(await requireManagementAuth(request, { alwaysRequireAuth: true })) === null;
|
||||
return NextResponse.json({
|
||||
webdavEnabled: status.webdavEnabled,
|
||||
webdavUsername: status.webdavEnabled ? status.webdavUsername : null,
|
||||
webdavPassword: status.webdavEnabled ? status.webdavPassword : null,
|
||||
webdavPassword:
|
||||
status.webdavEnabled && hasManagement ? status.webdavPassword : null,
|
||||
webdavPasswordSet: status.webdavEnabled && Boolean(status.webdavPassword),
|
||||
vaultPath: status.vaultPath,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -200,6 +200,14 @@ let _customAgentDefs: CustomAgentDef[] = [];
|
||||
|
||||
const DISALLOWED_VERSION_COMMAND_CHARS = /[;&|<>`$\r\n]/;
|
||||
|
||||
// A version probe only ever needs a version flag. For untrusted (client-registered)
|
||||
// custom agents the binary-match check alone is not enough: the caller controls both
|
||||
// `binary` and `versionCommand`, so a matching interpreter with an eval-style argument
|
||||
// (`node -e …`, `python -c …`, `ruby -e …`) reaches execFileSync as arbitrary code
|
||||
// execution without any shell metacharacter. Restricting the args to a recognized
|
||||
// version flag closes that path — see GHSA-jphr-2gw7-xrwp / GHSA-hf57-cqmx-p4gr.
|
||||
const SAFE_VERSION_PROBE_ARG = /^(-v|-V|--version|-version|version|--ver)$/;
|
||||
|
||||
/**
|
||||
* Set custom agent definitions from settings.
|
||||
*/
|
||||
@@ -300,6 +308,12 @@ export function resolveVersionProbe(
|
||||
if (!allowed.has(normalizedCommand)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Untrusted probe: allow only a bare binary or a single recognized version
|
||||
// flag, so a matching interpreter cannot smuggle an eval/exec argument.
|
||||
if (args.length > 1 || (args.length === 1 && !SAFE_VERSION_PROBE_ARG.test(args[0]))) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return { command, args };
|
||||
|
||||
@@ -7,7 +7,12 @@ import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { getServiceRow, updateServiceField, setToolStatus } from "@/lib/db/versionManager";
|
||||
import { RingBuffer } from "./ringBuffer";
|
||||
import { HealthChecker } from "./healthCheck";
|
||||
import { decidePreSpawn, probeBeforeSpawn, resolvePortPid } from "./portProbe";
|
||||
import {
|
||||
decidePreSpawn,
|
||||
isAdoptExistingEnabled,
|
||||
probeBeforeSpawn,
|
||||
resolvePortPid,
|
||||
} from "./portProbe";
|
||||
import type { ServiceConfig, ServiceState, ServiceStatus, LogLine, HealthState } from "./types";
|
||||
|
||||
const CRASH_FAST_THRESHOLD_MS = 5_000;
|
||||
@@ -111,7 +116,7 @@ export class ServiceSupervisor extends EventEmitter {
|
||||
// Opt-in per ServiceConfig so the default spawn path is unchanged.
|
||||
if (this.config.probeBeforeSpawn) {
|
||||
const probe = await probeBeforeSpawn(this.config.healthUrl(), this.config.port);
|
||||
const decision = decidePreSpawn(probe, this.config.port);
|
||||
const decision = decidePreSpawn(probe, this.config.port, isAdoptExistingEnabled());
|
||||
|
||||
if (decision.action === "adopt") {
|
||||
// Something healthy already serves this port. We didn't spawn it,
|
||||
|
||||
@@ -37,11 +37,30 @@ const PID_RESOLVE_TIMEOUT_MS = 2_000;
|
||||
*
|
||||
* Pure — no I/O — so it can be exhaustively unit-tested.
|
||||
*/
|
||||
export function decidePreSpawn(probe: PreSpawnProbe, port: number): PreSpawnDecision {
|
||||
// A healthy instance is already serving on the port — adopt it rather than
|
||||
// spawn a duplicate that would immediately die with EADDRINUSE.
|
||||
export function decidePreSpawn(
|
||||
probe: PreSpawnProbe,
|
||||
port: number,
|
||||
allowAdopt = false
|
||||
): PreSpawnDecision {
|
||||
if (probe.healthy) {
|
||||
return { action: "adopt" };
|
||||
// A 2xx on the health path does NOT prove the listener is our service: a
|
||||
// local process can squat the port, answer 200, and get adopted — receiving
|
||||
// the injected service API key and script execution inside the dashboard
|
||||
// origin (GHSA-wg9p-6m2g-4v27). Adopt an already-healthy listener only when
|
||||
// the operator explicitly opts in; otherwise surface the same actionable
|
||||
// error we already use for a held-but-unhealthy port instead of silently
|
||||
// trusting the listener.
|
||||
if (allowAdopt) {
|
||||
return { action: "adopt" };
|
||||
}
|
||||
return {
|
||||
action: "error",
|
||||
message:
|
||||
`Port ${port} is already serving a healthy response, but adopting an ` +
|
||||
`existing listener is disabled by default (a 2xx cannot prove the listener ` +
|
||||
`is this service). Set OMNIROUTE_ADOPT_EXISTING_SERVICE=1 to allow adoption, ` +
|
||||
`or stop the process holding the port and start the service again.`,
|
||||
};
|
||||
}
|
||||
// Port is held but nothing healthy answers: an orphaned or unrelated process
|
||||
// is squatting on it. Surface a clear, actionable error instead of letting
|
||||
@@ -59,6 +78,17 @@ export function decidePreSpawn(probe: PreSpawnProbe, port: number): PreSpawnDeci
|
||||
return { action: "spawn" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the operator opted in to adopting an already-healthy listener on a
|
||||
* service port. Off by default (GHSA-wg9p-6m2g-4v27): a squatter can answer a
|
||||
* 2xx, so auto-adoption is only safe when the operator knows the listener is
|
||||
* genuinely their (externally-managed) instance.
|
||||
*/
|
||||
export function isAdoptExistingEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const v = env.OMNIROUTE_ADOPT_EXISTING_SERVICE;
|
||||
return v === "1" || v === "true";
|
||||
}
|
||||
|
||||
/** TCP connect check: resolves true when something accepts a connection. */
|
||||
function isPortInUse(port: number, timeoutMs: number): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
|
||||
28
src/proxy.ts
28
src/proxy.ts
@@ -24,6 +24,14 @@ export async function proxy(request: NextRequest) {
|
||||
return runAuthzPipeline(request, { enforce: true });
|
||||
}
|
||||
|
||||
// Next compiles the middleware/proxy matcher from `regexp.source` only, dropping
|
||||
// path-to-regexp's default case-insensitive flag — so a lowercase literal like
|
||||
// `/v1/:path*` never matches `/V1/...`, while the rewrite matcher (flag kept)
|
||||
// still routes it to the handler. That skipped the authz pipeline entirely
|
||||
// (GHSA-jvqc-mp9f-q936). Expressing the case-insensitivity inside a custom
|
||||
// path-to-regexp group (`([vV]1)`) survives the flag-drop because it needs no
|
||||
// flag. Keep these in sync with the client-API aliases in
|
||||
// next.config.mjs rewrites and src/server/authz/classify.ts.
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/",
|
||||
@@ -31,15 +39,15 @@ export const config = {
|
||||
"/home",
|
||||
"/home/:path*",
|
||||
"/api/:path*",
|
||||
"/v1/:path*",
|
||||
"/v1",
|
||||
"/v1beta/:path*",
|
||||
"/v1beta",
|
||||
"/chat/:path*",
|
||||
"/responses/:path*",
|
||||
"/responses",
|
||||
"/codex/:path*",
|
||||
"/codex",
|
||||
"/models",
|
||||
"/:v1seg([vV]1)/:path*",
|
||||
"/:v1seg([vV]1)",
|
||||
"/:v1betaseg([vV]1[bB][eE][tT][aA])/:path*",
|
||||
"/:v1betaseg([vV]1[bB][eE][tT][aA])",
|
||||
"/:chatseg([cC][hH][aA][tT])/:path*",
|
||||
"/:respseg([rR][eE][sS][pP][oO][nN][sS][eE][sS])/:path*",
|
||||
"/:respseg([rR][eE][sS][pP][oO][nN][sS][eE][sS])",
|
||||
"/:codexseg([cC][oO][dD][eE][xX])/:path*",
|
||||
"/:codexseg([cC][oO][dD][eE][xX])",
|
||||
"/:modelsseg([mM][oO][dD][eE][lL][sS])",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -16,30 +16,39 @@ function normalizePathname(rawPath: string): { path: string; reason?: Classifica
|
||||
if (!path.startsWith("/")) path = "/" + path;
|
||||
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
|
||||
|
||||
if (path === "/codex" || path.startsWith("/codex/")) {
|
||||
// Client-API aliases are matched case-insensitively on the control segment.
|
||||
// Next's rewrite layer accepts `/V1/...`, `/CODEX`, etc. and routes them to
|
||||
// the client handler, so the classifier must recognize the same casing —
|
||||
// otherwise an uppercase alias falls through to the management fallback and
|
||||
// the request is treated as a different route class than it is actually
|
||||
// dispatched to (GHSA-jvqc-mp9f-q936). Only the leading control segment is
|
||||
// lowercased for detection; the original-case tail is preserved.
|
||||
const lower = path.toLowerCase();
|
||||
|
||||
if (lower === "/codex" || lower.startsWith("/codex/")) {
|
||||
return { path: "/api/v1/responses", reason: "client_api_codex_alias" };
|
||||
}
|
||||
|
||||
if (path === "/v1/v1" || path.startsWith("/v1/v1/")) {
|
||||
if (lower === "/v1/v1" || lower.startsWith("/v1/v1/")) {
|
||||
const tail = path.slice("/v1/v1".length) || "";
|
||||
return { path: "/api/v1" + tail, reason: "client_api_double_prefix" };
|
||||
}
|
||||
|
||||
if (path === "/v1beta" || path.startsWith("/v1beta/")) {
|
||||
if (lower === "/v1beta" || lower.startsWith("/v1beta/")) {
|
||||
const tail = path.slice("/v1beta".length) || "";
|
||||
return { path: "/api/v1beta" + tail, reason: "client_api_alias" };
|
||||
}
|
||||
|
||||
if (path === "/v1" || path.startsWith("/v1/")) {
|
||||
if (lower === "/v1" || lower.startsWith("/v1/")) {
|
||||
const tail = path.slice("/v1".length) || "";
|
||||
return { path: "/api/v1" + tail, reason: "client_api_alias" };
|
||||
}
|
||||
|
||||
for (const { alias, canonical } of CLIENT_API_ALIAS_PREFIXES) {
|
||||
if (path === alias) {
|
||||
if (lower === alias) {
|
||||
return { path: canonical, reason: "client_api_alias" };
|
||||
}
|
||||
if (path.startsWith(alias + "/")) {
|
||||
if (lower.startsWith(alias + "/")) {
|
||||
return { path: canonical + path.slice(alias.length), reason: "client_api_alias" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
|
||||
"/api/jobs", // JobRegistry control (enable/disable/run-now) + run history - runtime job administration, loopback-only (Hard Rules #15 + #17)
|
||||
"/api/jobs/", // sub-paths: /api/jobs/:id/{runs,enable,disable,run-now} (the bare `/api/jobs` above matches the list route; this matches children)
|
||||
"/api/oauth/cursor/auto-import", // spawns execFile("which", argv-array-of-one-arg "cursor") to verify a local Cursor install before importing creds — RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate). Specific path only: the rest of /api/oauth/ (browser redirect/callback flows) must stay remote-reachable. Note: this comment intentionally avoids a literal closing square bracket character — check-openapi-security-tiers.mjs's naive regex parser for this array stops at the first one it finds, silently truncating its view of every entry after this one.
|
||||
"/api/oauth/kiro/auto-import", // reads host-local Kiro credential files (homedir kiro-cli data) — must reach the loopback-only gate, not the PUBLIC /api/oauth/ prefix (GHSA-wgwc-crjm-pmwv, GHSA-gxv4-955v-v6cm). Excluded from PUBLIC in publicApiRoutes.ts.
|
||||
"/api/oauth/raycast/auto-import", // reads host-local Raycast credential files — same loopback-only rationale as the kiro and cursor auto-import routes above.
|
||||
"/api/skills/collect/", // Skill Collector CLI detection: GET .../detect probes getCliRuntimeStatus() per CLI_TOOL_IDS entry, which spawns a child process to check each tool — RCE-via-tunnel surface (Hard Rules #15 + #17, PR #6294 review).
|
||||
"/api/discovery/", // Discovery tool (opt-in provider scanner): the scan route makes outbound probes to provider endpoints (SSRF-adjacent) and the whole surface is an admin research tool — strict-loopback only, no manage-scope bypass (NOT in LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES). See _tasks/features-v3.8.42/gaps/DISCOVERY_TOOL_DESIGN.md.
|
||||
VNC_ROUTE_PREFIX, // #7892: /api/vnc-session/* spawns Docker containers via child_process.spawn (src/lib/vncSession/service.ts) — RCE-via-tunnel surface (Hard Rules #15 + #17), same CVE class (GHSA-fhh6-4qxv-rpqj).
|
||||
@@ -119,6 +121,11 @@ export const ALWAYS_PROTECTED_API_PATHS: ReadonlyArray<string> = [
|
||||
"/api/shutdown",
|
||||
"/api/providers/health-autopilot/actions",
|
||||
"/api/settings/database",
|
||||
// Full-database export/import: a credential dump and an irreversible replace.
|
||||
// Must stay authenticated even under requireLogin=false, for the same reason
|
||||
// /api/settings/database already does. isAlwaysProtectedPath matches on a path
|
||||
// boundary, so this covers export, exportAll and import. (GHSA-mghq-58h3-qcqj)
|
||||
"/api/db-backups",
|
||||
];
|
||||
|
||||
export function isLoopbackHost(hostHeader: string | null): boolean {
|
||||
|
||||
@@ -144,6 +144,26 @@ export function getCorsStatus(): CorsStatus {
|
||||
* compression middleware only appends it conditionally, so shared caches can't
|
||||
* otherwise reliably tell compressed vs uncompressed variants apart.
|
||||
*/
|
||||
function requestCarriesTokenOrPreflight(request: Request): boolean {
|
||||
// Preflight (OPTIONS) never carries the Authorization / x-api-key header, so it
|
||||
// must be allowed through — the actual request that follows is re-evaluated by
|
||||
// this same check and only gets the permissive Origin if it presents a token.
|
||||
if (request.method === "OPTIONS") return true;
|
||||
if (
|
||||
request.headers.get("authorization") ||
|
||||
request.headers.get("x-api-key") ||
|
||||
request.headers.get("x-goog-api-key")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// A dashboard session cookie is a credential too (#5242 browser/Electron
|
||||
// clients). auth_token is HttpOnly + SameSite, so a cross-site attacker page
|
||||
// cannot get it auto-attached — only a truly credential-less request (the
|
||||
// GHSA-7px7 anonymous case on a keyless install) falls through to fail-closed.
|
||||
const cookie = request.headers.get("cookie");
|
||||
return Boolean(cookie && /(?:^|;\s*)auth_token=/.test(cookie));
|
||||
}
|
||||
|
||||
export function applyCorsHeaders(
|
||||
response: Response,
|
||||
request: Request,
|
||||
@@ -151,7 +171,15 @@ export function applyCorsHeaders(
|
||||
): void {
|
||||
const requestOrigin = request.headers.get("origin");
|
||||
let allowed = resolveAllowedOrigin(requestOrigin);
|
||||
if (allowed === null && relaxForTokenAuth) {
|
||||
if (allowed === null && relaxForTokenAuth && requestCarriesTokenOrPreflight(request)) {
|
||||
// GHSA-7px7-29v2-m97p: the permissive Origin echo is only safe on the
|
||||
// assumption that these routes are token-authenticated (browsers never
|
||||
// auto-attach Authorization/x-api-key). On a keyless install that assumption
|
||||
// breaks — an anonymous cross-origin page would be echoed its own Origin and
|
||||
// could read the response. Only relax for a request that actually carries a
|
||||
// credential, plus CORS preflights (OPTIONS never carries the header — the
|
||||
// real request that follows is re-checked), so authenticated browser/Electron
|
||||
// clients (#5242) keep working while credential-less cross-origin reads do not.
|
||||
allowed = requestOrigin && requestOrigin.length > 0 ? requestOrigin : "*";
|
||||
}
|
||||
if (allowed !== null) {
|
||||
|
||||
@@ -71,7 +71,26 @@ function isPublicCloudApiRoute(pathname: string, method: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// OAuth "auto-import" routes read host-local credential files (Cursor / Kiro /
|
||||
// Raycast tokens). The broad `/api/oauth/` PUBLIC prefix would classify them
|
||||
// PUBLIC, which skips the LOCAL_ONLY tier entirely (GHSA-wgwc-crjm-pmwv) and
|
||||
// exposes the host credential to a remote caller (GHSA-gxv4-955v-v6cm). Exclude
|
||||
// them so they fall through to MANAGEMENT and reach the loopback-only gate.
|
||||
const LOCAL_ONLY_OAUTH_IMPORT_ROUTES = [
|
||||
"/api/oauth/cursor/auto-import",
|
||||
"/api/oauth/kiro/auto-import",
|
||||
"/api/oauth/raycast/auto-import",
|
||||
];
|
||||
|
||||
export function isPublicApiRoute(pathname: string, method = "GET"): boolean {
|
||||
if (
|
||||
LOCAL_ONLY_OAUTH_IMPORT_ROUTES.some(
|
||||
(route) => pathname === route || pathname.startsWith(`${route}/`)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPublicCloudApiRoute(pathname, method)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export const SPAWN_CAPABLE_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
/^\/api\/providers\/[^/]+\/login\/?$/, // pre-existing gap: in LOCAL_ONLY_API_PATTERNS today but never in a spawn-capable deny-list
|
||||
/^\/api\/providers\/[^/]+\/refresh-cursor\/?$/, // spawns cursor-agent via renewal.ts (Hard Rules #15 + #17)
|
||||
/^\/api\/providers\/cursor\/agent-availability\/?$/, // static path (no dynamic segment), but kept in this array alongside its /api/providers/ siblings rather than the flat SPAWN_CAPABLE_PREFIXES array — spawns cursor-agent status via checkCursorAgentAvailability()/getCachedCursorAgentAvailability() (Hard Rules #15 + #17)
|
||||
/^\/api\/providers\/[^/]+\/chatgpt-web-codex-doctor\/?$/, // spawns via getTunnelRuntimeStatus() → spawnSync("...","runtimes status") (open-sse/executors/chatgpt-web-codex/tunnelClient.ts). Mirrors LOCAL_ONLY_API_PATTERNS in routeGuard.ts; keep the two in sync (GHSA-9q3h-mjm5-f4gj).
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -645,13 +645,37 @@ async function validateRateLimitAndThrottle(context: PolicyContext): Promise<Res
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare `x-api-key` / `x-goog-api-key` (no anthropic-version, no claude
|
||||
* user-agent) is accepted by the CLIENT_API auth layer (clientApi.ts
|
||||
* `extractBearer`) but ignored by the Issue-#2225-gated `extractApiKey()` used
|
||||
* for policy resolution — so a genuine key sent that way passed auth while
|
||||
* skipping its own allowedModels / budget / rate-limit policy
|
||||
* (GHSA-2phc-xp22-9f56). Resolve those headers here so the policy layer sees the
|
||||
* same key auth accepted. Bearer, URL-token and anthropic-gated paths are already
|
||||
* covered by `extractApiKey()`; unknown keys still fail open downstream, so this
|
||||
* only tightens enforcement for real keys.
|
||||
*/
|
||||
function extractUngatedClientApiKey(request: Request): string | null {
|
||||
const xApiKey = request.headers.get("x-api-key") ?? request.headers.get("X-Api-Key");
|
||||
if (xApiKey && xApiKey.trim()) return xApiKey.trim();
|
||||
const xGoog = request.headers.get("x-goog-api-key") ?? request.headers.get("X-Goog-Api-Key");
|
||||
if (xGoog && xGoog.trim()) return xGoog.trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function enforceApiKeyPolicy(
|
||||
request: Request,
|
||||
modelStr: string | null
|
||||
): Promise<ApiKeyPolicyResult> {
|
||||
// A real bearer key wins; otherwise an authenticated dashboard playground may
|
||||
// test a specific key's policy by id (resolved server-side, secret never sent).
|
||||
const apiKey = extractApiKey(request) || (await resolvePlaygroundTestKey(request));
|
||||
// A real bearer key wins; then a bare x-api-key/x-goog-api-key that auth
|
||||
// accepted but extractApiKey() gates out; otherwise an authenticated dashboard
|
||||
// playground may test a specific key's policy by id (resolved server-side,
|
||||
// secret never sent).
|
||||
const apiKey =
|
||||
extractApiKey(request) ||
|
||||
extractUngatedClientApiKey(request) ||
|
||||
(await resolvePlaygroundTestKey(request));
|
||||
|
||||
// No API key = local/session mode, skip policy checks
|
||||
if (!apiKey) {
|
||||
|
||||
69
tests/unit/a2a-route-require-api-key.test.ts
Normal file
69
tests/unit/a2a-route-require-api-key.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* GHSA-v54m-6rm3-p565 — /a2a sits outside the authz proxy matcher, so it never
|
||||
* saw the REQUIRE_API_KEY posture and accepted every caller when OMNIROUTE_API_KEY
|
||||
* was unset (the default). authenticate() now honors REQUIRE_API_KEY directly.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-a2a-require-key-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "a2a-require-key-secret";
|
||||
process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const route = await import("../../src/app/a2a/route.ts");
|
||||
|
||||
const ORIGINAL_REQUIRE = process.env.REQUIRE_API_KEY;
|
||||
const ORIGINAL_A2A_KEY = process.env.OMNIROUTE_API_KEY;
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (ORIGINAL_REQUIRE === undefined) delete process.env.REQUIRE_API_KEY;
|
||||
else process.env.REQUIRE_API_KEY = ORIGINAL_REQUIRE;
|
||||
if (ORIGINAL_A2A_KEY === undefined) delete process.env.OMNIROUTE_API_KEY;
|
||||
else process.env.OMNIROUTE_API_KEY = ORIGINAL_A2A_KEY;
|
||||
});
|
||||
|
||||
function post(key?: string) {
|
||||
return route.POST(
|
||||
new Request("http://localhost/a2a", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(key ? { authorization: `Bearer ${key}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "message/send", params: {} }),
|
||||
}) as never
|
||||
);
|
||||
}
|
||||
|
||||
async function isUnauthorized(res: Response) {
|
||||
const body = (await res.clone().json()) as { error?: { code?: number } };
|
||||
return body.error?.code === -32600;
|
||||
}
|
||||
|
||||
test("REQUIRE_API_KEY=true rejects an unkeyed /a2a call (GHSA-v54m)", async () => {
|
||||
delete process.env.OMNIROUTE_API_KEY;
|
||||
process.env.REQUIRE_API_KEY = "true";
|
||||
assert.equal(await isUnauthorized(await post()), true, "no key must be rejected");
|
||||
|
||||
const key = await apiKeysDb.createApiKey("a2a-client", "machine-a2a", []);
|
||||
assert.equal(
|
||||
await isUnauthorized(await post(key.key)),
|
||||
false,
|
||||
"a valid key must clear the /a2a auth gate"
|
||||
);
|
||||
});
|
||||
|
||||
test("keyless local-first default still allows /a2a (posture preserved)", async () => {
|
||||
delete process.env.REQUIRE_API_KEY;
|
||||
delete process.env.OMNIROUTE_API_KEY;
|
||||
assert.equal(await isUnauthorized(await post()), false, "keyless default must not 401");
|
||||
});
|
||||
@@ -100,3 +100,32 @@ test("POST /api/acp/agents rejects unsafe version commands for authenticated ses
|
||||
assert.equal(response.status, 400);
|
||||
assert.match(body.error, /Invalid versionCommand/i);
|
||||
});
|
||||
|
||||
test("POST /api/acp/agents rejects an interpreter eval payload (GHSA-jphr-2gw7-xrwp)", async () => {
|
||||
// Exact shape of the advisory PoC: binary + versionCommand both name `node`,
|
||||
// so the binary-match check passes, but the `-e` eval argument must still be
|
||||
// refused before it can reach execFileSync("node", ["-e", ...]).
|
||||
process.env.JWT_SECRET = "acp-agents-jwt-secret";
|
||||
await localDb.updateSettings({ requireLogin: true, password: "hashed-password" });
|
||||
const token = await createSessionToken();
|
||||
|
||||
const response = await routeModule.POST(
|
||||
makeRequest(
|
||||
"POST",
|
||||
{
|
||||
id: "anonrce",
|
||||
name: "anonrce",
|
||||
binary: "node",
|
||||
versionCommand: 'node -e "process.exit(1)"',
|
||||
providerAlias: "anonrce",
|
||||
spawnArgs: [],
|
||||
protocol: "stdio",
|
||||
},
|
||||
token
|
||||
)
|
||||
);
|
||||
const body = (await response.json()) as { error?: string };
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.match(body.error ?? "", /Invalid versionCommand/i);
|
||||
});
|
||||
|
||||
@@ -32,6 +32,41 @@ test("resolveVersionProbe rejects shell metacharacters in version commands", ()
|
||||
assert.equal(probe, null);
|
||||
});
|
||||
|
||||
// Regression guard — GHSA-jphr-2gw7-xrwp / GHSA-hf57-cqmx-p4gr (ACP custom-agent
|
||||
// RCE). A client-registered custom agent controls both `binary` and
|
||||
// `versionCommand`; the binary-match check alone still admits an eval-style
|
||||
// argument on a matching interpreter, which reaches execFileSync as arbitrary
|
||||
// code execution (no shell metacharacter required). A version *probe* only ever
|
||||
// needs a version flag, so untrusted probes must reject non-version arguments.
|
||||
test("resolveVersionProbe rejects interpreter eval arguments on a matching binary", () => {
|
||||
assert.equal(resolveVersionProbe("node", 'node -e "process.exit(1)"', true), null);
|
||||
assert.equal(resolveVersionProbe("node", "node --eval 1", true), null);
|
||||
assert.equal(resolveVersionProbe("python3", 'python3 -c "import os"', true), null);
|
||||
assert.equal(resolveVersionProbe("ruby", 'ruby -e "puts 1"', true), null);
|
||||
// Any extra argument beyond a single version flag is refused for a probe.
|
||||
assert.equal(resolveVersionProbe("node", "node --version --eval 1", true), null);
|
||||
});
|
||||
|
||||
test("resolveVersionProbe still accepts legitimate version flags for custom agents", () => {
|
||||
assert.deepEqual(resolveVersionProbe("node", "node --version", true), {
|
||||
command: "node",
|
||||
args: ["--version"],
|
||||
});
|
||||
assert.deepEqual(resolveVersionProbe("my-agent", "my-agent -v", true), {
|
||||
command: "my-agent",
|
||||
args: ["-v"],
|
||||
});
|
||||
assert.deepEqual(resolveVersionProbe("my-agent", "my-agent version", true), {
|
||||
command: "my-agent",
|
||||
args: ["version"],
|
||||
});
|
||||
// Bare binary with no arguments is a valid probe too.
|
||||
assert.deepEqual(resolveVersionProbe("my-agent", "my-agent", true), {
|
||||
command: "my-agent",
|
||||
args: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("shouldUseShellForVersionProbe preserves Windows npm wrapper detection", () => {
|
||||
assert.equal(shouldUseShellForVersionProbe("codex", "win32"), true);
|
||||
assert.equal(
|
||||
|
||||
@@ -96,6 +96,17 @@ function makeAnthropicPolicyRequest(apiKey) {
|
||||
});
|
||||
}
|
||||
|
||||
// A bare `x-api-key` with NO anthropic-version header and no claude user-agent:
|
||||
// the CLIENT_API auth layer accepts it, but the gated extractApiKey() used by
|
||||
// the policy layer used to ignore it, so the key's per-key policy was skipped
|
||||
// entirely (GHSA-2phc-xp22-9f56).
|
||||
function makeBareXApiKeyPolicyRequest(apiKey) {
|
||||
return new Request("http://localhost/v1/responses", {
|
||||
method: "POST",
|
||||
headers: apiKey ? { "x-api-key": apiKey } : {},
|
||||
});
|
||||
}
|
||||
|
||||
async function readErrorMessage(response) {
|
||||
const body = (await response.json()) as { error?: { message?: unknown } };
|
||||
return typeof body.error?.message === "string" ? body.error.message : "";
|
||||
@@ -457,6 +468,29 @@ test("enforceApiKeyPolicy rejects disabled keys and blocked schedules", async ()
|
||||
assert.match(await readErrorMessage(blocked.rejection), /Access denied outside allowed hours/);
|
||||
});
|
||||
|
||||
test("enforceApiKeyPolicy enforces allowedModels for a bare x-api-key (GHSA-2phc-xp22-9f56)", async () => {
|
||||
const restrictedKey = await createKeyWithPolicy({
|
||||
allowedModels: ["openai/gpt-4.1"],
|
||||
});
|
||||
const policy = await loadPolicy("bare-x-api-key");
|
||||
|
||||
// Disallowed model via a bare x-api-key must be rejected, exactly as it is for
|
||||
// a Bearer token — the header used to carry the key must not weaken the policy.
|
||||
const disallowed = await policy.enforceApiKeyPolicy(
|
||||
makeBareXApiKeyPolicyRequest(restrictedKey.key),
|
||||
"anthropic/claude-3-7-sonnet"
|
||||
);
|
||||
assert.equal(disallowed.rejection.status, 403);
|
||||
assert.match(await readErrorMessage(disallowed.rejection), /not allowed/);
|
||||
|
||||
// The allowed model still passes through the same header.
|
||||
const allowed = await policy.enforceApiKeyPolicy(
|
||||
makeBareXApiKeyPolicyRequest(restrictedKey.key),
|
||||
"openai/gpt-4.1"
|
||||
);
|
||||
assert.equal(allowed.rejection, null);
|
||||
});
|
||||
|
||||
test("enforceApiKeyPolicy rejects disallowed models and exhausted budgets", async () => {
|
||||
const restrictedKey = await createKeyWithPolicy({
|
||||
allowedModels: ["openai/gpt-4.1"],
|
||||
|
||||
35
tests/unit/authz/oauth-autoimport-local-only.test.ts
Normal file
35
tests/unit/authz/oauth-autoimport-local-only.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isPublicApiRoute } from "../../../src/shared/constants/publicApiRoutes.ts";
|
||||
import { classifyRoute } from "../../../src/server/authz/classify.ts";
|
||||
import { isLocalOnlyPath } from "../../../src/server/authz/routeGuard.ts";
|
||||
|
||||
// GHSA-wgwc-crjm-pmwv / GHSA-gxv4-955v-v6cm — the OAuth auto-import routes read
|
||||
// host-local credential files. They must NOT be PUBLIC (which skips the LOCAL_ONLY
|
||||
// tier); they must classify MANAGEMENT and be loopback-gated.
|
||||
|
||||
const AUTO_IMPORT = [
|
||||
"/api/oauth/cursor/auto-import",
|
||||
"/api/oauth/kiro/auto-import",
|
||||
"/api/oauth/raycast/auto-import",
|
||||
];
|
||||
|
||||
test("OAuth auto-import routes are excluded from PUBLIC classification", () => {
|
||||
for (const p of AUTO_IMPORT) {
|
||||
assert.equal(isPublicApiRoute(p), false, `${p} must not be PUBLIC`);
|
||||
assert.equal(classifyRoute(p, "GET").routeClass, "MANAGEMENT", `${p} must classify MANAGEMENT`);
|
||||
}
|
||||
});
|
||||
|
||||
test("OAuth auto-import routes are LOCAL_ONLY (loopback-gated)", () => {
|
||||
for (const p of AUTO_IMPORT) {
|
||||
assert.equal(isLocalOnlyPath(p), true, `${p} must be LOCAL_ONLY`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the rest of /api/oauth/ (callbacks, browser flows) stays PUBLIC", () => {
|
||||
assert.equal(isPublicApiRoute("/api/oauth/cursor/callback"), true);
|
||||
assert.equal(isPublicApiRoute("/api/oauth/codex/authorize"), true);
|
||||
// A sibling that merely shares the prefix must not be swept in.
|
||||
assert.equal(isPublicApiRoute("/api/oauth/cursor/auto-import-status"), true);
|
||||
});
|
||||
@@ -55,15 +55,19 @@ test("proxy.ts delegates to runAuthzPipeline with enforce: true", () => {
|
||||
test("proxy.ts config.matcher covers every /api/* route plus dashboard and v1 aliases", () => {
|
||||
const content = fs.readFileSync("src/proxy.ts", "utf8");
|
||||
// Required prefixes — drop one and the corresponding routes go unguarded.
|
||||
// The client-API aliases use a case-insensitive path-to-regexp group
|
||||
// (`([vV]1)`) so `/V1/...` reaches the pipeline too — see
|
||||
// GHSA-jvqc-mp9f-q936 and tests/unit/authz/proxy-matcher-case.test.ts for the
|
||||
// semantic (compiled-matcher) coverage assertions.
|
||||
const requiredMatchers = [
|
||||
'"/api/:path*"',
|
||||
'"/dashboard/:path*"',
|
||||
'"/v1/:path*"',
|
||||
'"/v1beta/:path*"',
|
||||
'"/chat/:path*"',
|
||||
'"/responses/:path*"',
|
||||
'"/codex/:path*"',
|
||||
'"/models"',
|
||||
'"/:v1seg([vV]1)/:path*"',
|
||||
'"/:v1betaseg([vV]1[bB][eE][tT][aA])/:path*"',
|
||||
'"/:chatseg([cC][hH][aA][tT])/:path*"',
|
||||
'"/:respseg([rR][eE][sS][pP][oO][nN][sS][eE][sS])/:path*"',
|
||||
'"/:codexseg([cC][oO][dD][eE][xX])/:path*"',
|
||||
'"/:modelsseg([mM][oO][dD][eE][lL][sS])"',
|
||||
];
|
||||
for (const matcher of requiredMatchers) {
|
||||
assert.ok(
|
||||
|
||||
76
tests/unit/authz/proxy-matcher-case.test.ts
Normal file
76
tests/unit/authz/proxy-matcher-case.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import { config } from "../../../src/proxy.ts";
|
||||
import { classifyRoute } from "../../../src/server/authz/classify.ts";
|
||||
|
||||
// Regression guard — GHSA-jvqc-mp9f-q936 (case-sensitive authz-matcher bypass).
|
||||
//
|
||||
// Next.js compiles the middleware/proxy matcher from `regexp.source` only,
|
||||
// dropping path-to-regexp's default case-insensitive flag, so a lowercase
|
||||
// literal like `/v1/:path*` does NOT match `/V1/...`. The rewrite matcher keeps
|
||||
// the flag, so `/V1/chat/completions` was still rewritten to the handler while
|
||||
// skipping the authz pipeline entirely — an unauthenticated inference bypass.
|
||||
//
|
||||
// The fix expresses the case-insensitivity inside a path-to-regexp custom group
|
||||
// (`/:seg([vV]1)/:path*`), which survives the flag-drop because it needs no
|
||||
// flag. This test compiles the matcher exactly the way Next does and asserts the
|
||||
// uppercase / mixed-case client aliases are covered.
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { tryToParsePath } = require("next/dist/lib/try-to-parse-path.js");
|
||||
|
||||
function compiledMatcherRegexes(): RegExp[] {
|
||||
return (config.matcher as string[]).map((entry) => {
|
||||
const parsed = tryToParsePath(entry);
|
||||
// Mirror Next's middleware-route-matcher: source only, no flags.
|
||||
return new RegExp(parsed.regexStr as string);
|
||||
});
|
||||
}
|
||||
|
||||
function isMatchedByProxy(path: string): boolean {
|
||||
return compiledMatcherRegexes().some((re) => re.test(path));
|
||||
}
|
||||
|
||||
test("proxy matcher still covers the canonical lowercase client aliases", () => {
|
||||
for (const p of [
|
||||
"/v1/chat/completions",
|
||||
"/v1/models",
|
||||
"/v1beta/models",
|
||||
"/responses",
|
||||
"/codex/x",
|
||||
"/models",
|
||||
]) {
|
||||
assert.equal(isMatchedByProxy(p), true, `expected proxy matcher to cover ${p}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("proxy matcher covers uppercase / mixed-case client aliases (GHSA-jvqc-mp9f-q936)", () => {
|
||||
for (const p of [
|
||||
"/V1/chat/completions",
|
||||
"/V1/models",
|
||||
"/V1BETA/models",
|
||||
"/CHAT/completions",
|
||||
"/RESPONSES",
|
||||
"/CODEX/x",
|
||||
"/MODELS",
|
||||
"/Responses/x",
|
||||
"/v1BeTa/models",
|
||||
]) {
|
||||
assert.equal(
|
||||
isMatchedByProxy(p),
|
||||
true,
|
||||
`uppercase alias ${p} must reach the authz pipeline, not skip it`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("classifyRoute treats uppercase client aliases as CLIENT_API, not management fallback", () => {
|
||||
assert.equal(classifyRoute("/V1/chat/completions", "POST").routeClass, "CLIENT_API");
|
||||
assert.equal(classifyRoute("/V1BETA/models", "GET").routeClass, "CLIENT_API");
|
||||
assert.equal(classifyRoute("/MODELS", "GET").routeClass, "CLIENT_API");
|
||||
assert.equal(classifyRoute("/CODEX", "POST").routeClass, "CLIENT_API");
|
||||
// Lowercase behavior is unchanged.
|
||||
assert.equal(classifyRoute("/v1/chat/completions", "POST").routeClass, "CLIENT_API");
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
isAlwaysProtectedPath,
|
||||
isLoopbackHost,
|
||||
} from "../../../src/server/authz/routeGuard.ts";
|
||||
import { SPAWN_CAPABLE_PATTERNS } from "../../../src/shared/constants/spawnCapablePrefixes.ts";
|
||||
import { managementPolicy } from "../../../src/server/authz/policies/management.ts";
|
||||
import { getMachineTokenSync } from "../../../src/lib/machineToken.ts";
|
||||
import { CLI_TOKEN_HEADER } from "../../../src/server/authz/headers.ts";
|
||||
@@ -50,6 +51,27 @@ test("isLocalOnlyBypassableByManageScope: non-local-only routes are not bypassab
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/settings"), false);
|
||||
});
|
||||
|
||||
test("SPAWN_CAPABLE_PATTERNS covers every regex-tier LOCAL_ONLY spawn route (GHSA-9q3h-mjm5-f4gj)", () => {
|
||||
// The manage-scope bypass veto's precise early-deny keys on
|
||||
// SPAWN_CAPABLE_PATTERNS, so every LOCAL_ONLY_API_PATTERNS entry (a
|
||||
// spawn-capable regex route) must have a matching pattern here — otherwise the
|
||||
// two layers drift and a spawn route loses its exact early-deny. This guards
|
||||
// against the chatgpt-web-codex-doctor drift and any future one.
|
||||
const spawnRoutes = [
|
||||
"/api/providers/acct-1/login",
|
||||
"/api/providers/acct-1/refresh-cursor",
|
||||
"/api/providers/acct-1/chatgpt-web-codex-doctor",
|
||||
];
|
||||
for (const p of spawnRoutes) {
|
||||
assert.equal(isLocalOnlyPath(p), true, `${p} must be LOCAL_ONLY`);
|
||||
assert.equal(
|
||||
SPAWN_CAPABLE_PATTERNS.some((re) => re.test(p)),
|
||||
true,
|
||||
`${p} is a LOCAL_ONLY spawn route but SPAWN_CAPABLE_PATTERNS does not cover it`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("isAlwaysProtectedPath: /api/shutdown is always protected", () => {
|
||||
assert.equal(isAlwaysProtectedPath("/api/shutdown"), true);
|
||||
});
|
||||
@@ -58,6 +80,15 @@ test("isAlwaysProtectedPath: /api/settings/database is always protected", () =>
|
||||
assert.equal(isAlwaysProtectedPath("/api/settings/database"), true);
|
||||
});
|
||||
|
||||
test("isAlwaysProtectedPath: /api/db-backups is always protected (GHSA-mghq-58h3-qcqj)", () => {
|
||||
// Full-database read/replace must require auth even when requireLogin=false —
|
||||
// the same Tier-2 trade-off /api/settings/database already makes. The single
|
||||
// prefix entry covers export, exportAll, import and future siblings.
|
||||
assert.equal(isAlwaysProtectedPath("/api/db-backups/export"), true);
|
||||
assert.equal(isAlwaysProtectedPath("/api/db-backups/exportAll"), true);
|
||||
assert.equal(isAlwaysProtectedPath("/api/db-backups/import"), true);
|
||||
});
|
||||
|
||||
test("isAlwaysProtectedPath: ordinary settings routes are not always protected", () => {
|
||||
assert.equal(isAlwaysProtectedPath("/api/settings"), false);
|
||||
assert.equal(isAlwaysProtectedPath("/api/settings/proxy"), false);
|
||||
|
||||
38
tests/unit/base-executor-ssrf-guard.test.ts
Normal file
38
tests/unit/base-executor-ssrf-guard.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { DefaultExecutor } from "../../open-sse/executors/default.ts";
|
||||
|
||||
// GHSA-4f49-hj64-448x — a persisted, caller-supplied providerSpecificData.baseUrl
|
||||
// reaches fetch() on the runtime dispatch path with no SSRF guard. BaseExecutor
|
||||
// now mirrors the provider VALIDATION guard before every upstream fetch. In the
|
||||
// shipped default (block-metadata) mode the cloud-metadata IMDS pivot is blocked
|
||||
// for non-local providers, public upstreams pass, and local / self-hosted
|
||||
// providers (vLLM, LM Studio, Ollama, …) stay exempt so loopback/LAN keeps working.
|
||||
|
||||
function guardOf(provider: string) {
|
||||
const exec = new DefaultExecutor(provider) as unknown as {
|
||||
assertOutboundUrlAllowed(url: string): void;
|
||||
};
|
||||
return (url: string) => exec.assertOutboundUrlAllowed(url);
|
||||
}
|
||||
|
||||
test("BaseExecutor blocks cloud-metadata for a non-local provider (GHSA-4f49-hj64-448x)", () => {
|
||||
const guard = guardOf("openai");
|
||||
assert.throws(() => guard("http://169.254.169.254/latest/meta-data/iam/security-credentials/"));
|
||||
// IPv4-mapped IPv6 spelling of the same address (folded out by #10843).
|
||||
assert.throws(() => guard("http://[::ffff:169.254.169.254]/latest/meta-data/"));
|
||||
});
|
||||
|
||||
test("BaseExecutor allows a public upstream URL for a non-local provider", () => {
|
||||
const guard = guardOf("openai");
|
||||
assert.doesNotThrow(() => guard("https://api.openai.com/v1/chat/completions"));
|
||||
});
|
||||
|
||||
test("BaseExecutor exempts local / self-hosted providers from the outbound guard", () => {
|
||||
assert.doesNotThrow(() => guardOf("ollama-local")("http://127.0.0.1:11434/v1/chat/completions"));
|
||||
assert.doesNotThrow(() => guardOf("lm-studio")("http://192.168.1.50:1234/v1/chat/completions"));
|
||||
});
|
||||
|
||||
test("BaseExecutor guard is a no-op for an empty URL", () => {
|
||||
assert.doesNotThrow(() => guardOf("openai")(""));
|
||||
});
|
||||
@@ -1,224 +0,0 @@
|
||||
// Regression for #10954: `omniroute combo create` did not accept any way to
|
||||
// specify models — `bin/cli/commands/combo.mjs` only ever registered
|
||||
// `--strategy`, and both the HTTP body (POST /api/combos) and the local-db
|
||||
// fallback (db.combos.createCombo) hardcoded `models: []`. Every combo
|
||||
// created via the CLI came out empty.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Command } from "commander";
|
||||
|
||||
type CapturedOpts = Record<string, unknown>;
|
||||
|
||||
interface MockFetchInit {
|
||||
method?: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
const ORIGINAL_FETCH = globalThis.fetch;
|
||||
|
||||
function createTempDataDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cli-combo-models-"));
|
||||
}
|
||||
|
||||
async function withComboEnv(fn: (dataDir: string) => Promise<void>) {
|
||||
const dataDir = createTempDataDir();
|
||||
process.env.DATA_DIR = dataDir;
|
||||
// Mock fetch → simulates server offline so withRuntime falls back to DB.
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error("server offline");
|
||||
}) as typeof fetch;
|
||||
|
||||
const originalLog = console.log;
|
||||
console.log = () => {};
|
||||
|
||||
try {
|
||||
await fn(dataDir);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
globalThis.fetch = ORIGINAL_FETCH;
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
|
||||
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
}
|
||||
}
|
||||
|
||||
function makeHealthAndComboFetch(capture: { body: CapturedOpts | null }) {
|
||||
return (async (url: string, opts?: MockFetchInit) => {
|
||||
if (String(url).includes("/api/health")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ status: "ok" }),
|
||||
text: async () => "{}",
|
||||
headers: new Headers(),
|
||||
};
|
||||
}
|
||||
if (String(url).includes("/api/combos") && opts?.method === "POST") {
|
||||
capture.body = opts?.body ? JSON.parse(opts.body) : null;
|
||||
return {
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({ id: "combo-1", ...capture.body }),
|
||||
text: async () => JSON.stringify(capture.body),
|
||||
headers: new Headers(),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
// RED (on untouched code): `combo create` only registers `--strategy` — an
|
||||
// unrecognized `--models` option makes Commander (in strict `exitOverride`
|
||||
// mode) throw "unknown option '--models'" instead of parsing.
|
||||
test("combo create — parses --models without throwing (Commander option registered)", async () => {
|
||||
const { registerCombo } = await import("../../bin/cli/commands/combo.mjs");
|
||||
const { Command } = await import("commander");
|
||||
|
||||
const prog = new Command().exitOverride();
|
||||
registerCombo(prog);
|
||||
const comboCmd = prog.commands.find((c: Command) => c.name() === "combo") as Command;
|
||||
const createCmd = comboCmd.commands.find((c: Command) => c.name() === "create") as Command;
|
||||
|
||||
let capturedOpts: CapturedOpts | null = null;
|
||||
createCmd.action((_name: string, opts: CapturedOpts) => {
|
||||
capturedOpts = opts;
|
||||
});
|
||||
|
||||
await prog.parseAsync(
|
||||
["node", "x", "combo", "create", "my-combo", "--models", "openai/gpt-4o,anthropic/claude-3-opus"],
|
||||
{ from: "node" }
|
||||
);
|
||||
|
||||
assert.ok(capturedOpts, "action should have been called");
|
||||
assert.equal(capturedOpts.models, "openai/gpt-4o,anthropic/claude-3-opus");
|
||||
});
|
||||
|
||||
test("combo create — repeatable --model is registered and collected", async () => {
|
||||
const { registerCombo } = await import("../../bin/cli/commands/combo.mjs");
|
||||
const { Command } = await import("commander");
|
||||
|
||||
const prog = new Command().exitOverride();
|
||||
registerCombo(prog);
|
||||
const comboCmd = prog.commands.find((c: Command) => c.name() === "combo") as Command;
|
||||
const createCmd = comboCmd.commands.find((c: Command) => c.name() === "create") as Command;
|
||||
|
||||
let capturedOpts: CapturedOpts | null = null;
|
||||
createCmd.action((_name: string, opts: CapturedOpts) => {
|
||||
capturedOpts = opts;
|
||||
});
|
||||
|
||||
await prog.parseAsync(
|
||||
[
|
||||
"node",
|
||||
"x",
|
||||
"combo",
|
||||
"create",
|
||||
"my-combo",
|
||||
"--model",
|
||||
"openai/gpt-4o",
|
||||
"--model",
|
||||
"anthropic/claude-3-opus",
|
||||
],
|
||||
{ from: "node" }
|
||||
);
|
||||
|
||||
assert.deepEqual(capturedOpts.model, ["openai/gpt-4o", "anthropic/claude-3-opus"]);
|
||||
});
|
||||
|
||||
test("comboModels.resolveComboModels — parses CSV provider/model tokens", async () => {
|
||||
const { resolveComboModels } = await import("../../bin/cli/commands/comboModels.mjs");
|
||||
const models = resolveComboModels({ models: "openai/gpt-4o, anthropic/claude-3-opus" });
|
||||
assert.deepEqual(models, ["openai/gpt-4o", "anthropic/claude-3-opus"]);
|
||||
});
|
||||
|
||||
test("comboModels.resolveComboModels — parses a JSON array of structured entries", async () => {
|
||||
const { resolveComboModels } = await import("../../bin/cli/commands/comboModels.mjs");
|
||||
const models = resolveComboModels({
|
||||
models: JSON.stringify([
|
||||
{ model: "gpt-4o", providerId: "openai" },
|
||||
{ kind: "combo-ref", comboName: "fallback-combo" },
|
||||
]),
|
||||
});
|
||||
assert.deepEqual(models, [
|
||||
{ model: "gpt-4o", providerId: "openai" },
|
||||
{ kind: "combo-ref", comboName: "fallback-combo" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("comboModels.resolveComboModels — rejects an invalid JSON entry shape", async () => {
|
||||
const { resolveComboModels } = await import("../../bin/cli/commands/comboModels.mjs");
|
||||
assert.throws(
|
||||
() => resolveComboModels({ models: JSON.stringify([{ providerId: "openai" }]) }),
|
||||
/requires a non-empty "model"/
|
||||
);
|
||||
});
|
||||
|
||||
test("comboModels.resolveComboModels — merges --models and repeated --model", async () => {
|
||||
const { resolveComboModels } = await import("../../bin/cli/commands/comboModels.mjs");
|
||||
const models = resolveComboModels({
|
||||
models: "openai/gpt-4o",
|
||||
model: ["anthropic/claude-3-opus"],
|
||||
});
|
||||
assert.deepEqual(models, ["openai/gpt-4o", "anthropic/claude-3-opus"]);
|
||||
});
|
||||
|
||||
// GREEN: end-to-end through runComboCreateCommand — local-db fallback path.
|
||||
test("combo create (db fallback) — stores the parsed --models, no longer creates an empty combo", async () => {
|
||||
await withComboEnv(async () => {
|
||||
const { runComboCreateCommand } = await import("../../bin/cli/commands/combo.mjs");
|
||||
const { resolveComboModels } = await import("../../bin/cli/commands/comboModels.mjs");
|
||||
|
||||
const models = resolveComboModels({ models: "openai/gpt-4o,anthropic/claude-3-opus" });
|
||||
const result = await runComboCreateCommand("models-combo", "priority", { models });
|
||||
assert.equal(result, 0);
|
||||
|
||||
const { getComboByName } = await import("../../src/lib/db/combos.ts");
|
||||
const combo = await getComboByName("models-combo");
|
||||
assert.ok(combo);
|
||||
// The repository layer (src/lib/db/repositories/sqliteComboRepository.ts)
|
||||
// normalizes plain "provider/model" strings into structured ComboStep
|
||||
// objects on write — assert on the normalized shape rather than raw
|
||||
// string equality, and above all assert the combo is no longer empty
|
||||
// (the actual #10954 regression).
|
||||
const storedModels = combo.models as Array<Record<string, unknown>>;
|
||||
assert.equal(storedModels.length, 2, "combo must not be created empty");
|
||||
assert.equal(storedModels[0].model, "openai/gpt-4o");
|
||||
assert.equal(storedModels[0].providerId, "openai");
|
||||
assert.equal(storedModels[1].model, "anthropic/claude-3-opus");
|
||||
assert.equal(storedModels[1].providerId, "anthropic");
|
||||
});
|
||||
});
|
||||
|
||||
// GREEN: end-to-end through runComboCreateCommand — HTTP path, verifies the
|
||||
// POST /api/combos body actually carries the parsed models.
|
||||
test("combo create (HTTP) — POST /api/combos body carries the parsed models", async () => {
|
||||
const dataDir = createTempDataDir();
|
||||
process.env.DATA_DIR = dataDir;
|
||||
const capture: { body: CapturedOpts | null } = { body: null };
|
||||
globalThis.fetch = makeHealthAndComboFetch(capture);
|
||||
const originalLog = console.log;
|
||||
console.log = () => {};
|
||||
|
||||
try {
|
||||
const { runComboCreateCommand } = await import("../../bin/cli/commands/combo.mjs");
|
||||
const { resolveComboModels } = await import("../../bin/cli/commands/comboModels.mjs");
|
||||
|
||||
const models = resolveComboModels({ models: "openai/gpt-4o,anthropic/claude-3-opus" });
|
||||
const result = await runComboCreateCommand("http-models-combo", "priority", { models });
|
||||
|
||||
assert.equal(result, 0);
|
||||
assert.ok(capture.body, "POST /api/combos should have been called");
|
||||
assert.deepEqual(capture.body.models, ["openai/gpt-4o", "anthropic/claude-3-opus"]);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
globalThis.fetch = ORIGINAL_FETCH;
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
}
|
||||
});
|
||||
@@ -129,12 +129,12 @@ describe("cors/origins.applyCorsHeaders", () => {
|
||||
assert.match(res.headers.get("Vary") || "", /Origin/);
|
||||
});
|
||||
|
||||
it("CLIENT_API: echoes arbitrary Origin (+Vary) when no allowlist matches (relaxForTokenAuth)", () => {
|
||||
it("CLIENT_API: echoes arbitrary Origin (+Vary) for a token-carrying request (relaxForTokenAuth)", () => {
|
||||
// Token-authenticated /v1/* surface (issue #5242): no allowlist, arbitrary
|
||||
// origin → echo it back so browser/Electron renderers can read the body.
|
||||
const res = NextResponse.json({ ok: true });
|
||||
const req = new Request("https://server.example.com/api/v1/models", {
|
||||
headers: { Origin: "http://localhost" },
|
||||
headers: { Origin: "http://localhost", Authorization: "Bearer omr_test_key" },
|
||||
});
|
||||
applyCorsHeaders(res, req, true);
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Origin"), "http://localhost");
|
||||
@@ -143,14 +143,40 @@ describe("cors/origins.applyCorsHeaders", () => {
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Credentials"), null);
|
||||
});
|
||||
|
||||
it("CLIENT_API: returns '*' when no Origin header is present (relaxForTokenAuth)", () => {
|
||||
it("CLIENT_API: returns '*' when no Origin header is present for a token-carrying request", () => {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
const req = new Request("https://server.example.com/api/v1/models");
|
||||
const req = new Request("https://server.example.com/api/v1/models", {
|
||||
headers: { "x-api-key": "omr_test_key" },
|
||||
});
|
||||
applyCorsHeaders(res, req, true);
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Origin"), "*");
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Credentials"), null);
|
||||
});
|
||||
|
||||
it("CLIENT_API: does NOT echo the Origin for a credential-less cross-origin request (GHSA-7px7)", () => {
|
||||
// A keyless install serves /v1 anonymously; echoing the Origin to a
|
||||
// credential-less cross-origin page would let any visited page drive the
|
||||
// gateway. Only token-carrying requests get the permissive echo.
|
||||
const res = NextResponse.json({ ok: true });
|
||||
const req = new Request("https://server.example.com/api/v1/models", {
|
||||
headers: { Origin: "https://evil.example" },
|
||||
});
|
||||
applyCorsHeaders(res, req, true);
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Origin"), null);
|
||||
});
|
||||
|
||||
it("CLIENT_API: a CORS preflight (OPTIONS) is still allowed through (relaxForTokenAuth)", () => {
|
||||
// Preflight never carries the auth header; blocking it would break the
|
||||
// credentialed request that follows, so OPTIONS keeps the permissive echo.
|
||||
const res = new Response(null, { status: 204 });
|
||||
const req = new Request("https://server.example.com/api/v1/models", {
|
||||
method: "OPTIONS",
|
||||
headers: { Origin: "http://localhost" },
|
||||
});
|
||||
applyCorsHeaders(res, req, true);
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Origin"), "http://localhost");
|
||||
});
|
||||
|
||||
it("MANAGEMENT: stays fail-closed for arbitrary Origin with no allowlist (relax off)", () => {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
const req = new Request("https://server.example.com/api/keys", {
|
||||
@@ -213,7 +239,11 @@ describe("cors/origins.applyCorsHeaders", () => {
|
||||
|
||||
it("CLIENT_API: appends Vary: Accept-Encoding even without an Origin header (#6737)", () => {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
const req = new Request("https://server.example.com/api/v1/models");
|
||||
// Token-carrying request (post-GHSA-7px7 the permissive echo requires a
|
||||
// credential); this test's point is the Vary: Accept-Encoding stamp.
|
||||
const req = new Request("https://server.example.com/api/v1/models", {
|
||||
headers: { "x-api-key": "omr_test_key" },
|
||||
});
|
||||
applyCorsHeaders(res, req, true);
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Origin"), "*");
|
||||
assert.match(res.headers.get("Vary") || "", /Accept-Encoding/);
|
||||
|
||||
@@ -187,3 +187,37 @@ test("omniroute_memory_search: hardcoded fallback config has retrievalStrategy=e
|
||||
"fallback from catch path must use retrievalStrategy=exact"
|
||||
);
|
||||
});
|
||||
|
||||
// ── IDOR: the authenticated caller's principal must win over a caller-supplied
|
||||
// apiKeyId (GHSA-cpv3-xr7r-xf8q). With a resolvable caller (here: OMNIROUTE_API_KEY
|
||||
// on the stdio path → "env-key"), omniroute_memory_add must store under the
|
||||
// caller, NOT under the arbitrary apiKeyId in the tool arguments.
|
||||
test("omniroute_memory_add: caller principal wins over a spoofed apiKeyId (GHSA-cpv3)", async () => {
|
||||
const db = core.getDbInstance();
|
||||
const prevEnvKey = process.env.OMNIROUTE_API_KEY;
|
||||
process.env.OMNIROUTE_API_KEY = "test-mcp-caller-key";
|
||||
try {
|
||||
const { memoryTools } = await import("../../open-sse/mcp-server/tools/memoryTools.ts");
|
||||
const result = await memoryTools.omniroute_memory_add.handler({
|
||||
apiKeyId: "victim-b",
|
||||
type: "factual",
|
||||
key: "idor-k1",
|
||||
content: "owned-by-caller",
|
||||
});
|
||||
assert.equal(result.success, true, "add must succeed");
|
||||
|
||||
const rows = db
|
||||
.prepare("SELECT api_key_id FROM memories WHERE key = 'idor-k1'")
|
||||
.all() as Array<{ api_key_id: string }>;
|
||||
assert.equal(rows.length, 1, "exactly one memory row expected");
|
||||
assert.equal(
|
||||
rows[0].api_key_id,
|
||||
"env-key",
|
||||
"memory must be stored under the resolved caller (env-key), not the spoofed apiKeyId"
|
||||
);
|
||||
assert.notEqual(rows[0].api_key_id, "victim-b", "must NOT store under the caller-supplied id");
|
||||
} finally {
|
||||
if (prevEnvKey === undefined) delete process.env.OMNIROUTE_API_KEY;
|
||||
else process.env.OMNIROUTE_API_KEY = prevEnvKey;
|
||||
}
|
||||
});
|
||||
|
||||
48
tests/unit/monitoring-health-public-view.test.ts
Normal file
48
tests/unit/monitoring-health-public-view.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* GHSA-mvf8-qc78-5mxm — GET /api/monitoring/health returned host-fingerprinting
|
||||
* detail (version, node version, pid, memory, provider config) to anonymous
|
||||
* callers. It now serves only the liveness verdict to non-management callers.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { makeManagementSessionRequest } from "../helpers/managementSession.ts";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-health-view-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const route = await import("../../src/app/api/monitoring/health/route.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("anonymous health GET is reduced to liveness only (GHSA-mvf8)", async () => {
|
||||
const res = await route.GET(new Request("http://localhost/api/monitoring/health") as never);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
assert.ok("status" in body, "liveness status must be present for probes");
|
||||
// No host fingerprinting for an anonymous caller.
|
||||
const keys = Object.keys(body);
|
||||
const allowed = new Set(["status", "setupComplete"]);
|
||||
for (const k of keys) {
|
||||
assert.ok(allowed.has(k), `anonymous health view leaked field: ${k}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("management session sees the full health payload", async () => {
|
||||
const sessionReq = (await makeManagementSessionRequest(
|
||||
"http://localhost/api/monitoring/health"
|
||||
)) as unknown as NextRequest;
|
||||
const res = await route.GET(sessionReq as never);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
assert.ok(
|
||||
Object.keys(body).length > 2,
|
||||
"a management caller must still receive the detailed payload"
|
||||
);
|
||||
});
|
||||
@@ -70,11 +70,19 @@ describe("#6205 A — embed panel root no longer 404s", () => {
|
||||
// ─── SUB-BUG B: pre-spawn port/health decision ───────────────────────────────
|
||||
|
||||
describe("#6205 B — pre-spawn port probe avoids raw EADDRINUSE", () => {
|
||||
it("adopts a healthy existing instance (no spawn)", () => {
|
||||
const decision = decidePreSpawn({ healthy: true, portInUse: true }, 20130);
|
||||
it("adopts a healthy existing instance when adoption is opted in (no spawn)", () => {
|
||||
const decision = decidePreSpawn({ healthy: true, portInUse: true }, 20130, true);
|
||||
assert.equal(decision.action, "adopt");
|
||||
});
|
||||
|
||||
it("does NOT adopt a healthy listener by default — a 2xx cannot prove identity (GHSA-wg9p-6m2g-4v27)", () => {
|
||||
const decision = decidePreSpawn({ healthy: true, portInUse: true }, 20130);
|
||||
assert.equal(decision.action, "error");
|
||||
assert.match(decision.message, /adopt/i);
|
||||
assert.match(decision.message, /OMNIROUTE_ADOPT_EXISTING_SERVICE/);
|
||||
assert.ok(!decision.message.includes("at /"), "must not leak a stack trace");
|
||||
});
|
||||
|
||||
it("returns a clear error object (not a throw) when the port is held but unhealthy", () => {
|
||||
let decision;
|
||||
assert.doesNotThrow(() => {
|
||||
@@ -92,9 +100,10 @@ describe("#6205 B — pre-spawn port probe avoids raw EADDRINUSE", () => {
|
||||
assert.equal(decision.action, "spawn");
|
||||
});
|
||||
|
||||
it("adopts a healthy instance even if the TCP probe missed it", () => {
|
||||
// Health is authoritative: a 2xx means a real instance is serving.
|
||||
const decision = decidePreSpawn({ healthy: true, portInUse: false }, 20130);
|
||||
it("adopts a healthy instance (opted in) even if the TCP probe missed it", () => {
|
||||
// With adoption opted in, health is authoritative: a 2xx means a real
|
||||
// instance is serving even when the TCP connect probe raced and missed it.
|
||||
const decision = decidePreSpawn({ healthy: true, portInUse: false }, 20130, true);
|
||||
assert.equal(decision.action, "adopt");
|
||||
});
|
||||
});
|
||||
|
||||
54
tests/unit/oauth-device-code-region-ssrf.test.ts
Normal file
54
tests/unit/oauth-device-code-region-ssrf.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* GHSA-7x63-xvp5-w2jc — the kiro / amazon-q device-code action interpolates a
|
||||
* caller-supplied `region` into the AWS OIDC endpoint URLs that requestDeviceCode()
|
||||
* fetches. An attacker-shaped region (userinfo / fragment) re-points the outbound
|
||||
* host (SSRF → cloud metadata). The route must reject a non-canonical region with
|
||||
* a 400 before any outbound fetch.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-oauth-region-ssrf-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const route = await import("../../src/app/api/oauth/[provider]/[action]/route.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function deviceCode(provider: string, region: string) {
|
||||
const url =
|
||||
`http://localhost/api/oauth/${provider}/device-code` +
|
||||
`?startUrl=${encodeURIComponent("https://d-1234567890.awsapps.com/start")}` +
|
||||
`®ion=${encodeURIComponent(region)}`;
|
||||
return route.GET(new Request(url) as unknown as NextRequest, {
|
||||
params: Promise.resolve({ provider, action: "device-code" }),
|
||||
});
|
||||
}
|
||||
|
||||
test("kiro device-code rejects a non-canonical region before any outbound fetch (GHSA-7x63)", async () => {
|
||||
for (const bad of [
|
||||
"evil.com",
|
||||
"169.254.169.254",
|
||||
"us-east-1@169.254.169.254",
|
||||
"us-east-1#.amazonaws.com@evil.com",
|
||||
"us-east-1/../..",
|
||||
"US-EAST-1", // uppercase is not the canonical shape
|
||||
]) {
|
||||
const res = await deviceCode("kiro", bad);
|
||||
assert.equal(res.status, 400, `region "${bad}" must be rejected with 400`);
|
||||
}
|
||||
});
|
||||
|
||||
test("amazon-q device-code also validates region", async () => {
|
||||
const res = await deviceCode("amazon-q", "evil.com:1@169.254.169.254");
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
75
tests/unit/oauth-import-manage-scope.test.ts
Normal file
75
tests/unit/oauth-import-manage-scope.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* GHSA-mg76-rhpx-gvw3 / GHSA-gxv4-955v-v6cm — OAuth import / auto-import routes
|
||||
* create or read provider credentials. They were guarded only by isAuthenticated(),
|
||||
* which (because /api/oauth/ is PUBLIC-classified) accepts ANY valid client API key.
|
||||
* They must now require MANAGEMENT scope.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-oauth-import-manage-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "oauth-import-manage-secret";
|
||||
process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const codexImportToken = await import("../../src/app/api/oauth/codex/import-token/route.ts");
|
||||
const cursorAutoImport = await import("../../src/app/api/oauth/cursor/auto-import/route.ts");
|
||||
|
||||
test.before(async () => {
|
||||
process.env.JWT_SECRET = "oauth-import-manage-jwt";
|
||||
process.env.INITIAL_PASSWORD = "oauth-import-manage-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
delete process.env.JWT_SECRET;
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
});
|
||||
|
||||
function post(route: { POST: (r: Request) => Promise<Response> }, key?: string) {
|
||||
return route.POST(
|
||||
new Request("http://localhost/api/oauth/codex/import-token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(key ? { authorization: `Bearer ${key}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ accessToken: "x", name: "poc" }),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function get(route: { GET: (r: Request) => Promise<Response> }, key?: string) {
|
||||
return route.GET(
|
||||
new Request("http://localhost/api/oauth/cursor/auto-import", {
|
||||
headers: key ? { authorization: `Bearer ${key}` } : {},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
test("codex/import-token: non-manage key → 403, no key → 401, manage key passes the auth gate (GHSA-mg76)", async () => {
|
||||
const nonManage = await apiKeysDb.createApiKey("client", "machine-client", []);
|
||||
const manage = await apiKeysDb.createApiKey("admin", "machine-admin", ["manage"]);
|
||||
|
||||
assert.equal((await post(codexImportToken, nonManage.key)).status, 403, "non-manage key rejected");
|
||||
assert.equal((await post(codexImportToken)).status, 401, "no credential rejected");
|
||||
|
||||
const withManage = await post(codexImportToken, manage.key);
|
||||
assert.notEqual(withManage.status, 401, "manage key must clear the auth gate");
|
||||
assert.notEqual(withManage.status, 403, "manage key must clear the auth gate");
|
||||
});
|
||||
|
||||
test("cursor/auto-import: a non-manage key cannot read the host's Cursor token (GHSA-gxv4)", async () => {
|
||||
const nonManage = await apiKeysDb.createApiKey("client2", "machine-client2", []);
|
||||
assert.equal((await get(cursorAutoImport, nonManage.key)).status, 403, "non-manage key rejected");
|
||||
assert.equal((await get(cursorAutoImport)).status, 401, "no credential rejected");
|
||||
});
|
||||
@@ -114,7 +114,46 @@ test("POST with a valid temp dir → returns { username, password }, GET shows e
|
||||
const getBody = (await getRes.json()) as Record<string, unknown>;
|
||||
assert.equal(getBody.webdavEnabled, true);
|
||||
assert.ok(typeof getBody.webdavUsername === "string" && (getBody.webdavUsername as string).length > 0);
|
||||
assert.ok(typeof getBody.webdavPassword === "string" && (getBody.webdavPassword as string).length > 0);
|
||||
// Anonymous GET (this request carries no management credential): the plaintext
|
||||
// password is masked (GHSA-62vw), but the set/unset flag still reflects state.
|
||||
assert.equal(getBody.webdavPassword, null);
|
||||
assert.equal(getBody.webdavPasswordSet, true);
|
||||
} finally {
|
||||
fs.rmSync(vaultDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("GET masks the WebDAV password for anonymous callers but reveals it to a management session (GHSA-62vw)", async () => {
|
||||
const vaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-vault-62vw-"));
|
||||
try {
|
||||
// Enable WebDAV so there is a stored password to leak.
|
||||
const enableRes = await route.POST(
|
||||
makeRequest("http://localhost/api/settings/obsidian/webdav", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ vaultPath: vaultDir }),
|
||||
})
|
||||
);
|
||||
assert.equal(enableRes.status, 200);
|
||||
|
||||
// Anonymous (open-mode) caller: password masked, flag still set.
|
||||
const anonBody = (await (await route.GET(
|
||||
makeRequest("http://localhost/api/settings/obsidian/webdav")
|
||||
)).json()) as Record<string, unknown>;
|
||||
assert.equal(anonBody.webdavEnabled, true);
|
||||
assert.equal(anonBody.webdavPassword, null, "anonymous caller must not receive the plaintext password");
|
||||
assert.equal(anonBody.webdavPasswordSet, true);
|
||||
|
||||
// Genuine management session: the operator's reveal-password view still works.
|
||||
const sessionReq = (await makeManagementSessionRequest(
|
||||
"http://localhost/api/settings/obsidian/webdav"
|
||||
)) as unknown as NextRequest;
|
||||
const sessionBody = (await (await route.GET(sessionReq)).json()) as Record<string, unknown>;
|
||||
assert.ok(
|
||||
typeof sessionBody.webdavPassword === "string" &&
|
||||
(sessionBody.webdavPassword as string).length > 0,
|
||||
"a management session must still receive the plaintext password"
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(vaultDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user