Compare commits

..

13 Commits

Author SHA1 Message Date
Markus Hartung
b35f84ea04 merge updated tip into fix-11038 2026-08-21 20:47:17 -03:00
Markus Hartung
9349af29c4 fix(quality): rebaseline file-size baseline for modelCapabilities.ts drift (1016->1072) 2026-08-21 20:46:43 -03:00
Markus Hartung
1eeb657e93 merge release/v3.8.50 into fix/release-v3.8.50-basereds-build-docs-lint (keep HEAD 348 provider counts) 2026-08-21 20:30:05 -03:00
Diego Rodrigues de Sa e Souza
eb4fd74b13 fix(security): close remaining v3.8.50 advisories (batch 2 — 11 findings) (#11040)
5 — Fecha 11 achados restantes das advisories de segurança do v3.8.50 (batch 2), TDD. UNSTABLE é o base-red #9985 já rastreado.
2026-08-21 20:28:16 -03:00
Diego Rodrigues de Sa e Souza
99111f39fb fix(services): isolate probeBeforeSpawn adoption tests on distinct ports to stop order-dependent flake (#10523) (#11061)
5 — Fix do dono com TDD. Estado committed+pushed limpo (hold-vivo cedido por instrução direta do operador).
2026-08-21 20:27:49 -03:00
Diego Rodrigues de Sa e Souza
4e3e53ee4d fix(ssrf): honor local-first provider URL flag in outbound guard (#9123) (#11062)
5 — Fix do dono com TDD. Estado committed+pushed limpo (hold-vivo cedido por instrução direta do operador).
2026-08-21 20:27:46 -03:00
Diego Rodrigues de Sa e Souza
a928fad895 fix(command-code): surface reasoning-only output as content when no text-delta (#10986) (#11063)
5 — Fix do dono com TDD. Estado committed+pushed limpo (hold-vivo cedido por instrução direta do operador).
2026-08-21 20:27:43 -03:00
Diego Rodrigues de Sa e Souza
d61eec63b5 fix(dashboard): treat UncloseAI as a no-auth provider so the connect form no longer forces a fake API key (#8864) (#11064)
5 — Fix do dono com TDD. Estado committed+pushed limpo (hold-vivo cedido por instrução direta do operador).
2026-08-21 20:27:40 -03:00
Diego Rodrigues de Sa e Souza
861ac69e4b fix(providers): validate Dify keys against native /v1/chat-messages endpoint (#11002) (#11065)
5 — Fix do dono com TDD. Estado committed+pushed limpo (hold-vivo cedido por instrução direta do operador).
2026-08-21 20:27:36 -03:00
Diego Rodrigues de Sa e Souza
484cb6e562 fix: add static model catalog for v0-vercel-web web-cookie provider (#10990) (#11066)
5 — Fix do dono com TDD. Estado committed+pushed limpo (hold-vivo cedido por instrução direta do operador).
2026-08-21 20:27:33 -03:00
Diego Rodrigues de Sa e Souza
2ab16d3214 fix(cli): scoping android instrumentation-hook diagnosis to real platform signal (#10028) (#11068)
5 — Fix do dono com TDD. Estado committed+pushed limpo (hold-vivo cedido por instrução direta do operador).
2026-08-21 20:27:29 -03:00
Xiangzhe
c28c866e10 chore: retrigger CI (Quality Gates workflow never fired on PR open) 2026-08-21 15:07:48 -03:00
Xiangzhe
495516a8e7 fix(release): drain v3.8.50 base-reds — broken utilization import, stale provider docs, orphaned lint suppressions 2026-08-21 14:39:26 -03:00
60 changed files with 1146 additions and 115 deletions

View File

@@ -94,10 +94,15 @@ export function ensureAndroidCacheDir(options = {}) {
*/
export function isFatalInstrumentationHookFailure(text) {
if (!text) return false;
return (
/Unsupported platform:\s*android/i.test(text) ||
/error occurred while loading instrumentation hook/i.test(text)
);
// Next.js wraps ANY throw inside instrumentation.register() with the generic
// "An error occurred while loading instrumentation hook:" prefix, on every
// platform (node_modules/next/dist/server/web/globals.js). That prefix alone
// therefore cannot identify the Android/Termux cache-probe failure — a bare
// generic instrumentation error on win32/desktop would be misreported as the
// Android bug and hide the real cause. Only match when the text actually
// carries the Android platform marker that Next's getCacheDirectory() emits.
// #10028
return /Unsupported platform:\s*android/i.test(text);
}
/**

View File

@@ -0,0 +1 @@
- fix(cli): stop diagnosing every Next.js instrumentation-hook failure as the Android/Termux cache bug — only the Android "Unsupported platform: android" signal now triggers the Android hint, so a win32/desktop instrumentation error surfaces its real cause instead of a useless `mkdir -p ~/.cache` (#10028)

View File

@@ -0,0 +1 @@
- fix(services): isolate probeBeforeSpawn adoption tests on distinct ports to stop the order-dependent flake (#10523)

View File

@@ -0,0 +1 @@
- **Static model catalog for v0-vercel-web:** seed a static catalog for the v0-vercel-web web-cookie provider (v0-1.0-md, v0-1.5-lg, v0-1.5-md) so its dashboard "Available Models" / "Import from /models" UI serves a usable list instead of falling through to the route's 400 "does not support models listing" ([#10990](https://github.com/diegosouzapw/OmniRoute/issues/10990)).

View File

@@ -0,0 +1 @@
- fix(providers): validate Dify keys against its native /v1/chat-messages endpoint (#11002)

View File

@@ -0,0 +1 @@
- fix(dashboard): treat UncloseAI as a no-auth provider so the connect form no longer forces a fake API key (#8864)

View File

@@ -0,0 +1 @@
- fix(ssrf): make `getProviderOutboundGuard()` (used for search-provider connection validation, image generation and remote image fetch) honor the local-first default `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` the same way the chat validation guard already does, so a LAN-hosted SearXNG/Brave search provider works with only the LOCAL flag set instead of silently requiring `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` ([#9123](https://github.com/diegosouzapw/OmniRoute/issues/9123)).

View File

@@ -0,0 +1,3 @@
- fix(api): repair broken `@/lib/db/connections` import in the usage utilization route that failed the production build (#10939 follow-up)
- chore(docs): regenerate PROVIDER_REFERENCE and refresh README diagram SVGs to the real provider count (347)
- chore(lint): prune ESLint suppressions orphaned on the release branch

View File

@@ -0,0 +1 @@
- fix(quality): rebaseline file-size for modelCapabilities.ts (1016->1072) drift from merged tip fixes (#11034 et al)

View File

@@ -449,7 +449,8 @@
"src/shared/constants/providers/apikey/gateways.ts": 1298,
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1387,
"_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).",
"src/lib/modelCapabilities.ts": 1016,
"src/lib/modelCapabilities.ts": 1072,
"_rebaseline_2026_08_21_11034_effort_variants": "DRIFT do tip (base-red #9985): modelCapabilities.ts 1016->1072 (+56) acumulado por PRs ja mergeadas no release/v3.8.50 — principalmente #11034 (resolve effort-variant capabilities a partir do modelo base), alem de #10963/#11040/#10987 growth dos catalogos. Tip puro ficou vermelho neste gate; rebaseline no tip por push direto (owner pre-autorizou crescimento legitimo). Nao tocou no arquivo da #11038.",
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014,
"open-sse/config/imageRegistry.ts": 1034,
"src/sse/handlers/chatHelpers.ts": 1019,

View File

@@ -5,7 +5,11 @@ export const difyProvider: RegistryEntry = {
alias: "dify",
format: "openai",
executor: "default",
baseUrl: "https://api.dify.ai/v1/chat/completions",
// Dify does not serve /chat/completions — its native completion route is
// POST /v1/chat-messages (validated via the dedicated dify validator, #11002).
// Keep this as the bare API root so route suffixes build correctly and
// self-hosted instances can override the base URL per connection.
baseUrl: "https://api.dify.ai",
authType: "apikey",
authHeader: "bearer",
models: [{ id: "auto", name: "Auto" }],

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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({

View File

@@ -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");
}

View File

@@ -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({

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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 });
}
/**

View File

@@ -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";

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -99,6 +99,15 @@ const STATIC_MODEL_PROVIDERS: Record<string, () => Array<{ id: string; name: str
{ id: "google_scholar", name: "Google Scholar" },
{ id: "duckduckgo", name: "DuckDuckGo" },
],
"v0-vercel-web": () => [
// v0-vercel-web web-cookie codegen provider — no upstream /v1/models endpoint,
// no registry `models` and no discovery config, so seed the current v0 lineup
// as a static catalog mirroring the v0-vercel API provider (shared.ts) so the
// model-import UI serves a list instead of the tail 400 (#10990).
{ id: "v0-1.0-md", name: "V0 1.0 MD" },
{ id: "v0-1.5-lg", name: "V0 1.5 LG" },
{ id: "v0-1.5-md", name: "V0 1.5 MD" },
],
"venice-web": () => [
// Venice.ai web-cookie provider — no upstream /v1/models endpoint, so seed the
// current lineup as a static catalog (#6269). Venice rotates its catalog; keep

View File

@@ -108,6 +108,7 @@ import {
validateBytezProvider,
} from "./validation/webCookie";
import { validateAiHordeProvider } from "./validation/aihorde";
import { validateDifyProvider } from "./validation/dify";
import { validateAdobeFireflyProvider } from "./validation/adobeFirefly";
import {
validateV0VercelProvider,
@@ -230,6 +231,10 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
freebuff: validateFreebuffProvider,
"command-code": validateCommandCodeProvider,
huggingface: validateHuggingFaceProvider,
// #11002: Dify serves no OpenAI-compatible route — only POST /v1/chat-messages.
// The generic OpenAI-like probe 404s on /v1/models and /v1/chat/completions,
// so every real app key was misreported as "endpoint not supported".
dify: validateDifyProvider,
// #5422: auth-only probe — Bytez 404s on every chat model until the account adds it to
// its catalog, so the generic chat probe can't validate a fresh key.
bytez: validateBytezProvider,

View File

@@ -0,0 +1,86 @@
/**
* Dify key check. Dify (multi-app LLM "LLMOps" platform) does NOT expose an
* OpenAI-compatible HTTP API. Its native completion endpoint is
* `POST {base}/v1/chat-messages` (body `inputs`/`query`/`response_mode`/`user`
* — no `model`/`messages` envelope). There is no `/v1/models` listing, so the
* generic OpenAI-like probe (GET /v1/models → POST /v1/chat/completions)
* always 404s and every real Dify app key is misreported as
* "Provider validation endpoint not supported" (#11002).
*
* Dify itself returns a clean 401 {"code":"unauthorized"} for a bad app key on
* `/v1/chat-messages`, and 200 for a valid key, so a single POST there is the
* correct auth probe.
*/
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
import { normalizeBaseUrl } from "./urlHelpers";
import { toValidationErrorResult, validationWrite } from "./transport";
/**
* Shape a provider/connection base URL into the Dify native completion route.
* Accepts the cloud root (`https://api.dify.ai`), a `/v1` root, or a full
* `/v1/chat-messages` URL (e.g. a self-hosted instance) and always returns
* `{base}/v1/chat-messages`.
*/
export function resolveDifyChatMessagesUrl(baseUrl: string) {
const normalized = normalizeBaseUrl(baseUrl);
if (!normalized) return "";
const cleaned = normalized.replace(/\/chat-messages$/, "").replace(/\/v1$/, "");
return `${cleaned}/v1/chat-messages`;
}
/** Pure status→verdict mapping, unit-testable without network. */
export function difyValidationResultFromStatus(status: number) {
if (status === 401 || status === 403) {
return { valid: false, error: "Invalid API key" };
}
if (status >= 200 && status < 300) {
return { valid: true, error: null };
}
return { valid: false, error: `Dify validation failed (${status})` };
}
export async function validateDifyProvider({
apiKey,
providerSpecificData = {},
fetchImpl = validationWrite,
}: {
apiKey?: unknown;
providerSpecificData?: Record<string, unknown>;
fetchImpl?: typeof validationWrite;
}) {
const key = typeof apiKey === "string" ? apiKey.trim() : "";
if (!key) {
return { valid: false, error: "API key required" };
}
const specificBase =
typeof providerSpecificData?.baseUrl === "string" ? providerSpecificData.baseUrl.trim() : "";
const entryBase = (getRegistryEntry("dify")?.baseUrl as string) || "";
const probeUrl = resolveDifyChatMessagesUrl(specificBase || entryBase);
if (!probeUrl) {
return { valid: false, error: "Dify requires a Base URL" };
}
try {
const response = await fetchImpl(
probeUrl,
{
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
inputs: {},
query: "ping",
response_mode: "blocking",
user: "omniroute-key-check",
}),
},
false
);
return difyValidationResultFromStatus(response.status);
} catch (error) {
return toValidationErrorResult(error);
}
}

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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).

View File

@@ -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) {

View File

@@ -639,20 +639,6 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
text: "Dahl auto-generates tokens via https://inference.dahl.global/tokens. No signup needed. Rate limits apply. You can also add your own API key.",
},
},
uncloseai: {
id: "uncloseai",
alias: "unc",
name: "UncloseAI",
icon: "auto_awesome",
color: "#8B5CF6",
textIcon: "UN",
website: "https://uncloseai.com",
hasFree: true,
freeNote: "Free forever — no signup, no credit card. OpenAI-compatible endpoints.",
passthroughModels: true,
authHint:
"No auth required. API accepts any non-empty string as key for identification. If older built-in models return 404, use Available Models → Import from /models or Auto-Sync; verified live model: solidrust/Hermes-3-Llama-3.1-8B-AWQ.",
},
hackclub: {
id: "hackclub",
alias: "hc",

View File

@@ -175,6 +175,25 @@ export const NOAUTH_PROVIDERS = {
text: "ZCode runs locally through its native app-server. OmniRoute never receives or stores the Z.ai credential.",
},
},
uncloseai: {
id: "uncloseai",
alias: "unc",
name: "UncloseAI",
icon: "auto_awesome",
color: "#8B5CF6",
textIcon: "UN",
website: "https://uncloseai.com",
noAuth: true,
hasFree: true,
passthroughModels: true,
serviceKinds: ["llm"],
authHint:
"No auth required. API accepts any non-empty string as key for identification. If older built-in models return 404, use Available Models → Import from /models or Auto-Sync; verified live model: solidrust/Hermes-3-Llama-3.1-8B-AWQ.",
freeNote: "Free forever — no signup, no credit card. OpenAI-compatible endpoints.",
notice: {
text: "UncloseAI needs no API key. API accepts any non-empty string as key for identification. If older built-in models return 404, use Available Models → Import from /models or Auto-Sync.",
},
},
aihorde: {
id: "aihorde",
alias: "horde",

View File

@@ -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;
}

View File

@@ -56,8 +56,18 @@ export function arePrivateProviderUrlsAllowed() {
return false;
}
/**
* Guard mode for the provider OUTBOUND path (search-provider connection validation, image
* generation, remote image fetch, model discovery — anything that does not go through the
* chat validation path). Precedence — mirrors `getProviderValidationGuard()` (#9123):
* 1. explicit full opt-in (`arePrivateProviderUrlsAllowed`) → "none" (no checks; power users).
* 2. local-first default (`areLocalProviderUrlsAllowed`) → "block-metadata" (allow LAN, block IMDS).
* 3. otherwise → "public-only" (strict).
*/
export function getProviderOutboundGuard(): OutboundUrlGuardMode {
return arePrivateProviderUrlsAllowed() ? "none" : "public-only";
if (arePrivateProviderUrlsAllowed()) return "none";
if (areLocalProviderUrlsAllowed()) return "block-metadata";
return "public-only";
}
/**

View File

@@ -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) {

View 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");
});

View File

@@ -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"],

View 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);
});

View 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")(""));
});

View File

@@ -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/);

View File

@@ -0,0 +1,100 @@
import test from "node:test";
import assert from "node:assert/strict";
import { after, before } from "node:test";
import { createServer, type Server } from "node:http";
import { readFileSync } from "node:fs";
import { validateProviderApiKey } from "../../src/lib/providers/validation.ts";
import {
difyValidationResultFromStatus,
resolveDifyChatMessagesUrl,
} from "../../src/lib/providers/validation/dify.ts";
import { difyProvider } from "../../open-sse/config/providers/registry/dify/index.ts";
// #11002 — the `dify` provider is registered with format:"openai", so the generic OpenAI-like
// validation probe hits GET /v1/models then POST /v1/chat/completions. Dify's native API serves
// neither — it only exposes POST /v1/chat-messages (401 {"code":"unauthorized"} for a bad key).
// Every real Dify app key therefore fails validation with the generic
// "Provider validation endpoint not supported" instead of a clean invalid/valid verdict.
//
// The fake upstream below is Dify-faithful: /v1/models and /v1/chat/completions 404, while
// /v1/chat-messages is the only route and answers 401 for a bad key.
let server: Server;
let baseUrl = "";
before(async () => {
server = createServer((req, res) => {
const path = (req.url || "").split("?")[0];
if (path === "/v1/models") {
res.writeHead(404, { "content-type": "text/plain" });
res.end("Not Found");
} else if (path === "/v1/chat/completions") {
res.writeHead(404, { "content-type": "text/html" });
res.end("<html>404 Not Found</html>");
} else if (path === "/v1/chat-messages") {
res.writeHead(401, { "content-type": "application/json" });
res.end(JSON.stringify({ code: "unauthorized", message: "Access token is invalid" }));
} else {
res.writeHead(404, { "content-type": "text/plain" });
res.end("Not Found");
}
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("no assigned port");
baseUrl = `http://127.0.0.1:${address.port}`;
});
after(async () => {
await new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve()))
);
});
test("#11002 dify key validation probes /v1/chat-messages and rejects a bad key", async () => {
const result = await validateProviderApiKey({
provider: "dify",
apiKey: "app-test-key",
providerSpecificData: { baseUrl },
});
assert.equal(result.valid, false);
assert.equal(result.error, "Invalid API key");
});
test("#11002 dify status→result maps bad keys and valid keys", () => {
assert.deepEqual(difyValidationResultFromStatus(401), {
valid: false,
error: "Invalid API key",
});
assert.deepEqual(difyValidationResultFromStatus(403), {
valid: false,
error: "Invalid API key",
});
assert.deepEqual(difyValidationResultFromStatus(200), { valid: true, error: null });
assert.deepEqual(difyValidationResultFromStatus(500), {
valid: false,
error: "Dify validation failed (500)",
});
});
test("#11002 resolveDifyChatMessagesUrl always targets /v1/chat-messages", () => {
assert.equal(resolveDifyChatMessagesUrl("https://api.dify.ai"), "https://api.dify.ai/v1/chat-messages");
assert.equal(
resolveDifyChatMessagesUrl("https://selfhosted.example.com/v1"),
"https://selfhosted.example.com/v1/chat-messages"
);
assert.equal(
resolveDifyChatMessagesUrl("https://selfhosted.example.com/v1/chat-messages"),
"https://selfhosted.example.com/v1/chat-messages"
);
});
test("#11002 dify registry baseUrl is the bare API root, not /chat/completions", () => {
assert.equal(difyProvider.baseUrl, "https://api.dify.ai");
const src = readFileSync(
new URL("../../src/lib/providers/validation.ts", import.meta.url),
"utf8"
);
assert.match(src, /dify:\s*validateDifyProvider/);
});

View File

@@ -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;
}
});

View 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"
);
});

View File

@@ -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");
});
});

View 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")}` +
`&region=${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);
});

View 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");
});

View File

@@ -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 });
}

View File

@@ -0,0 +1,49 @@
import { test } from "node:test";
import assert from "node:assert/strict";
const ENV_KEYS = [
"OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS",
"OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS",
"OUTBOUND_SSRF_GUARD_ENABLED",
];
function withEnv(overrides: Record<string, string | undefined>, fn: () => void) {
const saved: Record<string, string | undefined> = {};
for (const k of ENV_KEYS) saved[k] = process.env[k];
for (const k of ENV_KEYS) delete process.env[k];
Object.assign(process.env, overrides);
try {
fn();
} finally {
for (const k of ENV_KEYS) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
}
}
test("#9123: setting ONLY OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS=true should relax the guard used for search-provider outbound calls (currently does not)", async () => {
const { areLocalProviderUrlsAllowed, getProviderOutboundGuard } = await import(
"../../src/shared/network/outboundUrlGuardPolicy.ts"
);
withEnv({ OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS: "true" }, () => {
assert.equal(areLocalProviderUrlsAllowed(), true);
assert.notEqual(
getProviderOutboundGuard(),
"public-only",
"BUG #9123: OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS should relax getProviderOutboundGuard() " +
"(used for search-provider outbound calls) the same way it already relaxes " +
"getProviderValidationGuard() for regular chat providers — it currently has no effect."
);
});
});
test("#9123 control: OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true DOES relax the same guard", async () => {
const { getProviderOutboundGuard } = await import(
"../../src/shared/network/outboundUrlGuardPolicy.ts"
);
withEnv({ OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS: "true" }, () => {
assert.equal(getProviderOutboundGuard(), "none");
});
});

View File

@@ -24,7 +24,8 @@
// (base-reds round 3, #9985) are both included in that measurement; Cursor API (specialty-media,
// #10729) brings it to 229; Token Kiosk (gateways, #10722) — merged in the same
// merge-train batch — independently bumped the gateways family too, landing at 231; Freebuff
// (gateways, #10531) brings it to 232.
// (gateways, #10531) brings it to 232. #8864 moves uncloseai (gateways family) into
// NOAUTH_PROVIDERS, dropping the APIKEY_PROVIDERS count to 231.
import { test } from "node:test";
import assert from "node:assert/strict";
@@ -53,12 +54,12 @@ test("barrel still exports every catalog + key helpers", () => {
}
});
test("APIKEY_PROVIDERS merges the 6 family files into 232 entries (no loss / no dup)", async () => {
test("APIKEY_PROVIDERS merges the 6 family files into 231 entries (no loss / no dup)", async () => {
const keys = Object.keys((P as Record<string, object>).APIKEY_PROVIDERS);
assert.equal(keys.length, 232);
assert.equal(new Set(keys).size, 232, "duplicate keys after spread-merge");
assert.equal(keys.length, 231);
assert.equal(new Set(keys).size, 231, "duplicate keys after spread-merge");
// the merged object's entry-count equals the sum of the 6 semantic family files; families are a
// strict partition (every provider in exactly one), so the sum must be exactly 232.
// strict partition (every provider in exactly one), so the sum must be exactly 231.
const families: [string, string][] = [
["gateways", "APIKEY_PROVIDERS_GATEWAYS"],
["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"],
@@ -78,7 +79,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 232 entries (no loss / no
seen.add(k);
}
}
assert.equal(famTotal, 232, "families must partition all 232 providers");
assert.equal(famTotal, 231, "families must partition all 231 providers");
});
test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => {

View File

@@ -0,0 +1,21 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { NOAUTH_PROVIDERS, providerAllowsOptionalApiKey } from "@/shared/constants/providers";
test("uncloseai should be treated as a no-auth provider", () => {
const isNoAuthRegistered = Object.prototype.hasOwnProperty.call(NOAUTH_PROVIDERS, "uncloseai");
const allowsOptionalKey = providerAllowsOptionalApiKey("uncloseai");
assert.equal(
isNoAuthRegistered || allowsOptionalKey,
true,
"uncloseai must be registered in NOAUTH_PROVIDERS or allow an optional API key " +
"so the dashboard doesn't force users to enter a key for a no-auth provider"
);
assert.equal(
isNoAuthRegistered,
true,
"uncloseai must be registered in NOAUTH_PROVIDERS (not just allow an optional key) " +
"so the dashboard renders the NoAuthProviderControls flow"
);
});

View File

@@ -0,0 +1,25 @@
/**
* #10990 — v0-vercel-web (a web-cookie codegen provider) shipped a web-cookie
* executor but no registry `models`, no discovery config, and no static-catalog
* entry, so its "Import from /models" fell through to the models route's tail 400
* ("Provider v0-vercel-web does not support models listing"). Adding a
* `v0-vercel-web` entry to STATIC_MODEL_PROVIDERS gives the models route a local
* catalog to serve (same class as the #6269 venice-web / #7820 amazon-q fix).
*
* Kept as a standalone unit against the pure `getStaticModelsForProvider` resolver
* rather than extending the frozen `provider-models-route.test.ts` god-file.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { getStaticModelsForProvider } from "../../src/lib/providers/staticModels.ts";
test("#10990 v0-vercel-web resolves a non-empty static local catalog", () => {
const models = getStaticModelsForProvider("v0-vercel-web");
assert.ok(models && models.length > 0, "v0-vercel-web should expose a static catalog");
const ids = models.map((m) => m.id);
assert.ok(
ids.includes("v0-1.5-lg"),
`expected v0-1.5-lg in [${ids.join(", ")}]`
);
});

View File

@@ -244,8 +244,16 @@ test("#6205: probeBeforeSpawn adopts a healthy existing instance (no spawn)", as
// healthy, running service as untrustworthy/stale. This asserts the resolved
// pid on adoption matches the real process actually holding the port.
test("adopted service resolves and records the real pid of the process holding the port", async () => {
const healthServer = startHealthServer(29996);
const cfg = { ...tickConfig("test-adopt", 29996), probeBeforeSpawn: true };
// Use a distinct port from the other probeBeforeSpawn adoption test above.
// Both originally shared 29996, and Node's undici fetch() keep-alive pool
// (used by isHealthy() in portProbe.ts) caches a socket keyed only by
// host:port, so the second test's fetch could be replayed over a stale
// connection from the first test's health server instance, failing the
// probe and flipping the adoption into a spurious "error" state. A separate
// port keeps each probe isolated from the other test's pooled connection
// (#10523).
const healthServer = startHealthServer(29995);
const cfg = { ...tickConfig("test-adopt", 29995), probeBeforeSpawn: true };
const sup = new ServiceSupervisor(cfg);
try {

View File

@@ -166,6 +166,19 @@ test("isFatalInstrumentationHookFailure: matches Next.js android + hook errors",
assert.equal(isFatalInstrumentationHookFailure(""), false);
});
test("isFatalInstrumentationHookFailure: BUG #10028 — generic non-Android instrumentation failure is not Android/Termux", () => {
// Next.js wraps ANY throw inside instrumentation.register() with the generic
// "An error occurred while loading instrumentation hook:" prefix, on every
// platform. Without an actual Android/"Unsupported platform:" signal, that
// generic wrapper must NOT be diagnosed as the Android/Termux cache-dir bug,
// or a plain win32/desktop failure gets a useless `mkdir -p ~/.cache` hint
// and the real cause is hidden.
const genericWindowsFailure =
"Error: An error occurred while loading instrumentation hook: " +
"Cannot find module 'C:\\Users\\dev\\.omniroute\\config.json'";
assert.equal(isFatalInstrumentationHookFailure(genericWindowsFailure), false);
});
test("formatAndroidInstrumentationFailureHint: names the cache dir and TERMUX_GUIDE", () => {
const hint = formatAndroidInstrumentationFailureHint("/data/home/.cache");
assert.match(hint, /\/data\/home\/\.cache/);

View File

@@ -0,0 +1,16 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// #9985 base-red (introduced by #10939): the utilization route imported
// `getConnection` from "@/lib/db/connections", a module that does not exist —
// typecheck:core does not cover app routes, so only `next build` (and any
// runtime import, like this test) catches it. Importing the route module is
// the repro: ERR_MODULE_NOT_FOUND before the fix, resolves after.
test("utilization route module resolves all its imports (#10939 broken-import regression)", async () => {
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-utilization-import-"));
const mod = await import("../../src/app/api/usage/utilization/route.ts");
assert.equal(typeof mod.GET, "function", "route must export a GET handler");
});