mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 07:02:16 +03:00
Compare commits
13 Commits
fix/10986-
...
fix/releas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b35f84ea04 | ||
|
|
9349af29c4 | ||
|
|
1eeb657e93 | ||
|
|
eb4fd74b13 | ||
|
|
99111f39fb | ||
|
|
4e3e53ee4d | ||
|
|
a928fad895 | ||
|
|
d61eec63b5 | ||
|
|
861ac69e4b | ||
|
|
484cb6e562 | ||
|
|
2ab16d3214 | ||
|
|
c28c866e10 | ||
|
|
495516a8e7 |
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
1
changelog.d/fixes/10028-windows-instrumentation-hook.md
Normal file
1
changelog.d/fixes/10028-windows-instrumentation-hook.md
Normal 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)
|
||||
1
changelog.d/fixes/10523-servicesupervisor-port-flake.md
Normal file
1
changelog.d/fixes/10523-servicesupervisor-port-flake.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(services): isolate probeBeforeSpawn adoption tests on distinct ports to stop the order-dependent flake (#10523)
|
||||
1
changelog.d/fixes/10990-v0-vercel-web-static-catalog.md
Normal file
1
changelog.d/fixes/10990-v0-vercel-web-static-catalog.md
Normal 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)).
|
||||
1
changelog.d/fixes/11002-dify-key-validation.md
Normal file
1
changelog.d/fixes/11002-dify-key-validation.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): validate Dify keys against its native /v1/chat-messages endpoint (#11002)
|
||||
1
changelog.d/fixes/8864-uncloseai-noauth.md
Normal file
1
changelog.d/fixes/8864-uncloseai-noauth.md
Normal 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)
|
||||
@@ -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)).
|
||||
3
changelog.d/fixes/release-v3850-basereds.md
Normal file
3
changelog.d/fixes/release-v3850-basereds.md
Normal 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
|
||||
1
changelog.d/maintenance/11038-filesize-baseline-fix.md
Normal file
1
changelog.d/maintenance/11038-filesize-baseline-fix.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(quality): rebaseline file-size for modelCapabilities.ts (1016->1072) drift from merged tip fixes (#11034 et al)
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
86
src/lib/providers/validation/dify.ts
Normal file
86
src/lib/providers/validation/dify.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
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")(""));
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
100
tests/unit/dify-key-validation-repro.test.ts
Normal file
100
tests/unit/dify-key-validation-repro.test.ts
Normal 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/);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
49
tests/unit/outbound-url-guard-local-flag.test.ts
Normal file
49
tests/unit/outbound-url-guard-local-flag.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
21
tests/unit/providers/uncloseai-noauth.test.ts
Normal file
21
tests/unit/providers/uncloseai-noauth.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
25
tests/unit/repro-10990-v0-vercel-web-static-models.test.ts
Normal file
25
tests/unit/repro-10990-v0-vercel-web-static-models.test.ts
Normal 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(", ")}]`
|
||||
);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/);
|
||||
|
||||
16
tests/unit/utilization-route-import-10939.test.ts
Normal file
16
tests/unit/utilization-route-import-10939.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user