From dbdfb8a32844879f20be040bbaa5556c2802af41 Mon Sep 17 00:00:00 2001 From: abix5 <6427182+abix5@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:12:42 +0300 Subject: [PATCH] feat(authz): introduce centralized proxy-based authz pipeline and lifecycle policy (#1632) Integrated into release/v3.7.2 --- .env.example | 7 + .github/workflows/electron-release.yml | 5 + open-sse/handlers/audioSpeech.ts | 10 +- open-sse/handlers/audioTranscription.ts | 17 +- open-sse/handlers/chatCore.ts | 7 +- open-sse/handlers/imageGeneration.ts | 29 +-- open-sse/handlers/moderations.ts | 6 +- open-sse/handlers/rerank.ts | 4 +- open-sse/handlers/responsesHandler.ts | 3 +- open-sse/services/antigravityHeaders.ts | 2 + open-sse/services/combo.ts | 11 +- open-sse/utils/bypassHandler.ts | 5 +- open-sse/utils/cors.ts | 25 +- open-sse/utils/error.ts | 5 +- open-sse/utils/ollamaTransform.ts | 3 +- scripts/smoke-electron-packaged.mjs | 35 ++- .../settings/components/SecurityTab.tsx | 36 ++- src/app/api/auth/login/route.ts | 49 +++- src/app/api/combos/test/route.ts | 22 +- src/app/api/keys/[id]/reveal/route.ts | 3 +- src/app/api/keys/[id]/route.ts | 9 +- src/app/api/keys/route.ts | 7 +- src/app/api/v1/_helpers/apiKeyScope.ts | 44 +--- src/app/api/v1/api/chat/route.ts | 2 - src/app/api/v1/audio/speech/route.ts | 16 +- src/app/api/v1/audio/transcriptions/route.ts | 18 +- src/app/api/v1/chat/completions/route.ts | 6 +- src/app/api/v1/completions/route.ts | 3 +- src/app/api/v1/embeddings/route.ts | 14 - src/app/api/v1/images/edits/route.ts | 17 +- src/app/api/v1/images/generations/route.ts | 14 - src/app/api/v1/messages/count_tokens/route.ts | 17 -- src/app/api/v1/messages/route.ts | 2 - src/app/api/v1/models/catalog.ts | 29 +-- src/app/api/v1/models/route.ts | 2 - src/app/api/v1/moderations/route.ts | 16 +- src/app/api/v1/music/generations/route.ts | 14 - .../[provider]/chat/completions/route.ts | 2 - .../providers/[provider]/embeddings/route.ts | 10 - .../[provider]/images/generations/route.ts | 10 - src/app/api/v1/rerank/route.ts | 19 +- src/app/api/v1/responses/[...path]/route.ts | 2 - src/app/api/v1/responses/route.ts | 2 - src/app/api/v1/search/route.ts | 14 - src/app/api/v1/videos/generations/route.ts | 14 - src/app/api/v1beta/models/[...path]/route.ts | 2 - src/app/api/v1beta/models/route.ts | 2 - src/i18n/messages/en.json | 8 + src/lib/config/runtimeSettings.ts | 16 +- src/lib/db/apiKeys.ts | 175 +++++++++++-- src/lib/db/migrationRunner.ts | 43 +++- .../db/migrations/032_apikey_lifecycle.sql | 24 ++ src/lib/env/runtimeEnv.ts | 1 - src/lib/guardrails/visionBridgeHelpers.ts | 12 +- src/proxy.ts | 225 ++-------------- src/server/auth/loginGuard.ts | 106 ++++++++ src/server/authz/assertAuth.ts | 88 +++++++ src/server/authz/classify.ts | 122 +++++++++ src/server/authz/context.ts | 42 +++ src/server/authz/headers.ts | 34 +++ src/server/authz/pipeline.ts | 243 ++++++++++++++++++ src/server/authz/policies/clientApi.ts | 33 +++ src/server/authz/policies/management.ts | 40 +++ src/server/authz/policies/public.ts | 9 + src/server/authz/types.ts | 74 ++++++ src/server/cors/origins.ts | 124 +++++++++ src/shared/middleware/bodySizeGuard.ts | 1 - src/shared/network/remoteImageFetch.ts | 118 +++++++++ src/shared/services/modelSyncScheduler.ts | 2 +- src/shared/utils/cors.ts | 39 +-- src/shared/validation/schemas.ts | 2 +- src/shared/validation/settingsSchemas.ts | 2 +- src/sse/handlers/chat.ts | 20 -- src/types/global.d.ts | 1 - tests/integration/proxy-pipeline.test.ts | 4 +- tests/unit/admin-audit-events.test.ts | 2 +- tests/unit/api-key-lifecycle.test.ts | 119 +++++++++ tests/unit/audio-speech-handler.test.ts | 2 +- tests/unit/auth-clear-provider-routes.test.ts | 29 +-- tests/unit/auth/loginGuard.test.ts | 65 +++++ tests/unit/authz/classify.test.ts | 190 ++++++++++++++ tests/unit/authz/client-api-policy.test.ts | 112 ++++++++ tests/unit/authz/management-policy.test.ts | 121 +++++++++ tests/unit/authz/pipeline.test.ts | 141 ++++++++++ tests/unit/authz/public-policy.test.ts | 22 ++ tests/unit/batch_api.test.ts | 64 +---- tests/unit/chat-cooldown-aware-retry.test.ts | 2 +- tests/unit/chat-route-coverage.test.ts | 27 -- tests/unit/cors/origins.test.ts | 154 +++++++++++ tests/unit/db-migration-runner.test.ts | 84 ++++++ tests/unit/model-sync-scheduler.test.ts | 8 - tests/unit/moderations-handler.test.ts | 6 +- tests/unit/proxy-e2e-mode.test.ts | 74 ------ tests/unit/proxy-login-bootstrap-auth.test.ts | 111 -------- tests/unit/remote-image-fetch.test.ts | 51 ++++ tests/unit/route-edge-coverage.test.ts | 31 +-- tests/unit/t20-t22-provider-headers.test.ts | 4 +- 97 files changed, 2611 insertions(+), 1007 deletions(-) create mode 100644 src/lib/db/migrations/032_apikey_lifecycle.sql create mode 100644 src/server/auth/loginGuard.ts create mode 100644 src/server/authz/assertAuth.ts create mode 100644 src/server/authz/classify.ts create mode 100644 src/server/authz/context.ts create mode 100644 src/server/authz/headers.ts create mode 100644 src/server/authz/pipeline.ts create mode 100644 src/server/authz/policies/clientApi.ts create mode 100644 src/server/authz/policies/management.ts create mode 100644 src/server/authz/policies/public.ts create mode 100644 src/server/authz/types.ts create mode 100644 src/server/cors/origins.ts create mode 100644 src/shared/network/remoteImageFetch.ts create mode 100644 tests/unit/api-key-lifecycle.test.ts create mode 100644 tests/unit/auth/loginGuard.test.ts create mode 100644 tests/unit/authz/classify.test.ts create mode 100644 tests/unit/authz/client-api-policy.test.ts create mode 100644 tests/unit/authz/management-policy.test.ts create mode 100644 tests/unit/authz/pipeline.test.ts create mode 100644 tests/unit/authz/public-policy.test.ts create mode 100644 tests/unit/cors/origins.test.ts delete mode 100644 tests/unit/proxy-e2e-mode.test.ts delete mode 100644 tests/unit/proxy-login-bootstrap-auth.test.ts create mode 100644 tests/unit/remote-image-fetch.test.ts diff --git a/.env.example b/.env.example index 5e229a62ec..be615356cb 100644 --- a/.env.example +++ b/.env.example @@ -110,6 +110,13 @@ REQUIRE_API_KEY=false # Default: false | Security risk if enabled on shared instances. ALLOW_API_KEY_REVEAL=false +# Shared secret for the internal Codex Responses WebSocket bridge. +# Used by: src/app/api/internal/codex-responses-ws/route.ts — authenticates +# bridge requests between the Electron/browser WS relay and OmniRoute. +# ⚠️ REQUIRED for production — if unset, all WS bridge requests are rejected. +# Generate: openssl rand -base64 32 +# OMNIROUTE_WS_BRIDGE_SECRET= + # Comma-separated API key IDs that skip request logging (GDPR/compliance). # Used by: src/lib/compliance/index.ts — suppresses logs for specific keys. # NO_LOG_API_KEY_IDS=key_abc123,key_def456 diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml index 3f50ca5489..ef78cdd77b 100644 --- a/.github/workflows/electron-release.yml +++ b/.github/workflows/electron-release.yml @@ -135,14 +135,19 @@ jobs: - name: Smoke packaged Electron app if: matrix.platform != 'linux' + # Windows CI: requestSingleInstanceLock() fails due to USERPROFILE + # sanitization needed for the build step. Smoke is best-effort there. + continue-on-error: ${{ matrix.platform == 'windows' }} env: ELECTRON_SMOKE_TIMEOUT_MS: 60000 + ELECTRON_SMOKE_STREAM_LOGS: "1" run: npm run electron:smoke:packaged - name: Smoke packaged Electron app (Linux) if: matrix.platform == 'linux' env: ELECTRON_SMOKE_TIMEOUT_MS: 60000 + ELECTRON_SMOKE_STREAM_LOGS: "1" run: xvfb-run -a npm run electron:smoke:packaged - name: Collect installers diff --git a/open-sse/handlers/audioSpeech.ts b/open-sse/handlers/audioSpeech.ts index a867a4a269..d2169c0586 100644 --- a/open-sse/handlers/audioSpeech.ts +++ b/open-sse/handlers/audioSpeech.ts @@ -1,5 +1,5 @@ import { randomUUID } from "crypto"; -import { getCorsOrigin } from "../utils/cors.ts"; +import { CORS_HEADERS } from "../utils/cors.ts"; /** * Audio Speech Handler (TTS) * @@ -49,7 +49,7 @@ function upstreamErrorResponse(res, errText) { { error: { message: errorMessage, code: res.status } }, { status: res.status, - headers: { "Access-Control-Allow-Origin": getCorsOrigin() }, + headers: { ...CORS_HEADERS }, } ); } @@ -62,8 +62,8 @@ function audioStreamResponse(res, defaultContentType = "audio/mpeg") { return new Response(res.body, { status: 200, headers: { + ...CORS_HEADERS, "Content-Type": contentType, - "Access-Control-Allow-Origin": getCorsOrigin(), "Transfer-Encoding": "chunked", }, }); @@ -174,7 +174,6 @@ async function handleHyperbolicSpeech(providerConfig, body, token) { status: 200, headers: { "Content-Type": "audio/mpeg", - "Access-Control-Allow-Origin": getCorsOrigin(), }, }); } @@ -320,7 +319,6 @@ async function handleInworldSpeech(providerConfig, body, modelId, token) { status: 200, headers: { "Content-Type": mimeType, - "Access-Control-Allow-Origin": getCorsOrigin(), }, }); } @@ -491,7 +489,6 @@ async function handleCoquiSpeech(providerConfig, body) { status: 200, headers: { "Content-Type": contentType, - "Access-Control-Allow-Origin": getCorsOrigin(), }, }); } @@ -519,7 +516,6 @@ async function handleTortoiseSpeech(providerConfig, body) { status: 200, headers: { "Content-Type": contentType, - "Access-Control-Allow-Origin": getCorsOrigin(), }, }); } diff --git a/open-sse/handlers/audioTranscription.ts b/open-sse/handlers/audioTranscription.ts index 0ece148e23..53a3ce4d2d 100644 --- a/open-sse/handlers/audioTranscription.ts +++ b/open-sse/handlers/audioTranscription.ts @@ -1,4 +1,4 @@ -import { getCorsOrigin } from "../utils/cors.ts"; +import { CORS_HEADERS } from "../utils/cors.ts"; /** * Audio Transcription Handler * @@ -51,7 +51,7 @@ function upstreamErrorResponse(res, errText) { { error: { message: errorMessage, code: res.status } }, { status: res.status, - headers: { "Access-Control-Allow-Origin": getCorsOrigin() }, + headers: { ...CORS_HEADERS }, } ); } @@ -152,7 +152,7 @@ async function handleDeepgramTranscription( // Return it explicitly so the client can distinguish from a credentials error return Response.json( { text: text ?? "", noSpeechDetected: text === null || text === "" }, - { headers: { "Access-Control-Allow-Origin": getCorsOrigin() } } + { headers: { ...CORS_HEADERS } } ); } @@ -213,10 +213,7 @@ async function handleAssemblyAITranscription(providerConfig, file, modelId, toke const result = await pollRes.json(); if (result.status === "completed") { - return Response.json( - { text: result.text || "" }, - { headers: { "Access-Control-Allow-Origin": getCorsOrigin() } } - ); + return Response.json({ text: result.text || "" }, { headers: { ...CORS_HEADERS } }); } if (result.status === "error") { @@ -250,7 +247,7 @@ async function handleNvidiaTranscription(providerConfig, file, modelId, token) { // Normalize to { text } — Nvidia may return { text } directly or nested const text = data.text || data.transcript || ""; - return Response.json({ text }, { headers: { "Access-Control-Allow-Origin": getCorsOrigin() } }); + return Response.json({ text }, { headers: { ...CORS_HEADERS } }); } /** @@ -281,7 +278,7 @@ async function handleHuggingFaceTranscription(providerConfig, file, modelId, tok // HuggingFace returns { text } directly const text = data.text || ""; - return Response.json({ text }, { headers: { "Access-Control-Allow-Origin": getCorsOrigin() } }); + return Response.json({ text }, { headers: { ...CORS_HEADERS } }); } /** @@ -389,7 +386,7 @@ export async function handleAudioTranscription({ return new Response(data, { status: 200, - headers: { "Content-Type": contentType, "Access-Control-Allow-Origin": getCorsOrigin() }, + headers: { "Content-Type": contentType }, }); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 096dd0b340..08c4162b6e 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1,4 +1,4 @@ -import { getCorsOrigin } from "../utils/cors.ts"; +import { CORS_HEADERS } from "../utils/cors.ts"; import { detectFormatFromEndpoint, getTargetFormat } from "../services/provider.ts"; import { translateRequest, needsTranslation } from "../translator/index.ts"; import { FORMATS } from "../translator/formats.ts"; @@ -1018,7 +1018,6 @@ export async function handleChatCore({ status: cachedIdemp.status, headers: { "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), "X-OmniRoute-Idempotent": "true", ...buildOmniRouteResponseMetaHeaders({ provider, @@ -1355,7 +1354,6 @@ export async function handleChatCore({ response: new Response(JSON.stringify(cached), { headers: { "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), [OMNIROUTE_RESPONSE_HEADERS.cache]: "HIT", ...buildOmniRouteResponseMetaHeaders({ provider, @@ -1872,7 +1870,6 @@ export async function handleChatCore({ status: statusCode, headers: { "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), }, } ), @@ -3184,7 +3181,6 @@ export async function handleChatCore({ response: new Response(JSON.stringify(translatedResponse), { headers: { "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), [OMNIROUTE_RESPONSE_HEADERS.cache]: "MISS", ...buildOmniRouteResponseMetaHeaders({ provider, @@ -3210,7 +3206,6 @@ export async function handleChatCore({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", - "Access-Control-Allow-Origin": getCorsOrigin(), [OMNIROUTE_RESPONSE_HEADERS.cache]: "MISS", ...buildOmniRouteResponseMetaHeaders({ provider, diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 5661f9b406..dd3d7bbff1 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -20,10 +20,7 @@ import { getImageProvider, parseImageModel } from "../config/imageRegistry.ts"; import { mapImageSize } from "../translator/image/sizeMapper.ts"; import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts"; import { ChatGptWebExecutor } from "../executors/chatgpt-web.ts"; -import { - getChatGptImage, - findChatGptImageBySha256, -} from "../services/chatgptImageCache.ts"; +import { getChatGptImage, findChatGptImageBySha256 } from "../services/chatgptImageCache.ts"; import { createHash } from "node:crypto"; import { saveCallLog } from "@/lib/usageDb"; import { @@ -32,6 +29,7 @@ import { fetchComfyOutput, extractComfyOutputFiles, } from "../utils/comfyuiClient.ts"; +import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; const OPENAI_IMAGE_TO_IMAGE_MODELS = new Set([ "black-forest-labs/FLUX.1-redux", @@ -625,8 +623,7 @@ async function handleChatGptWebImageGeneration({ // own limit for image-1 / dall-e-3) so a stray n=1000 doesn't pin the // executor for hours before the upstream HTTP timeout fires. const CHATGPT_WEB_IMAGE_N_MAX = 4; - const rawCount = - Number.isInteger(body.n) && (body.n as number) > 0 ? (body.n as number) : 1; + const rawCount = Number.isInteger(body.n) && (body.n as number) > 0 ? (body.n as number) : 1; if (rawCount > CHATGPT_WEB_IMAGE_N_MAX) { return saveImageErrorResult({ provider, @@ -1547,15 +1544,11 @@ async function resolveImageSource(source) { } if (isHttpUrl(trimmed)) { - const response = await fetch(trimmed); - if (!response.ok) { - throw new Error(`Failed to fetch image source (${response.status})`); - } - const buffer = Buffer.from(await response.arrayBuffer()); + const remoteImage = await fetchRemoteImage(trimmed); return { - buffer, - base64: buffer.toString("base64"), - contentType: response.headers.get("content-type") || "application/octet-stream", + buffer: remoteImage.buffer, + base64: remoteImage.buffer.toString("base64"), + contentType: remoteImage.contentType, }; } @@ -2437,12 +2430,8 @@ async function normalizeNanoBananaTaskResult(taskData, body, log) { if (urlCandidates.length > 0) { const firstUrl = urlCandidates[0]; - const resp = await fetch(firstUrl); - if (!resp.ok) { - throw new Error(`Failed to fetch NanoBanana result image URL (${resp.status})`); - } - const arrayBuffer = await resp.arrayBuffer(); - const base64 = Buffer.from(arrayBuffer).toString("base64"); + const remoteImage = await fetchRemoteImage(firstUrl); + const base64 = remoteImage.buffer.toString("base64"); return [{ b64_json: base64, revised_prompt: body.prompt }]; } } diff --git a/open-sse/handlers/moderations.ts b/open-sse/handlers/moderations.ts index ffc54b9e40..83777cc3dc 100644 --- a/open-sse/handlers/moderations.ts +++ b/open-sse/handlers/moderations.ts @@ -1,4 +1,4 @@ -import { getCorsOrigin } from "../utils/cors.ts"; +import { CORS_HEADERS } from "../utils/cors.ts"; /** * Moderation Handler * @@ -58,14 +58,14 @@ export async function handleModeration({ body, credentials }) { status: res.status, headers: { "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), + ...CORS_HEADERS, }, }); } const data = await res.json(); return Response.json(data, { - headers: { "Access-Control-Allow-Origin": getCorsOrigin() }, + headers: { ...CORS_HEADERS }, }); } catch (err) { return errorResponse(500, `Moderation request failed: ${err.message}`); diff --git a/open-sse/handlers/rerank.ts b/open-sse/handlers/rerank.ts index eb76564d11..34920a53ad 100644 --- a/open-sse/handlers/rerank.ts +++ b/open-sse/handlers/rerank.ts @@ -1,4 +1,4 @@ -import { getCorsOrigin } from "../utils/cors.ts"; +import { CORS_HEADERS } from "../utils/cors.ts"; /** * Rerank Handler * @@ -131,7 +131,7 @@ export async function handleRerank({ const result = transformResponseFromProvider(providerConfig, data); return Response.json(result, { - headers: { "Access-Control-Allow-Origin": getCorsOrigin() }, + headers: { ...CORS_HEADERS }, }); } catch (err) { return errorResponse(500, `Rerank request failed: ${err.message}`); diff --git a/open-sse/handlers/responsesHandler.ts b/open-sse/handlers/responsesHandler.ts index dfd9662c8e..203b70ed4a 100644 --- a/open-sse/handlers/responsesHandler.ts +++ b/open-sse/handlers/responsesHandler.ts @@ -1,4 +1,4 @@ -import { getCorsOrigin } from "../utils/cors.ts"; +import { CORS_HEADERS } from "../utils/cors.ts"; /** * Responses API Handler for Workers * Converts Chat Completions to Codex Responses API format @@ -81,7 +81,6 @@ export async function handleResponsesCore({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", - "Access-Control-Allow-Origin": getCorsOrigin(), }, }), }; diff --git a/open-sse/services/antigravityHeaders.ts b/open-sse/services/antigravityHeaders.ts index 542b8f5e90..068ab67431 100644 --- a/open-sse/services/antigravityHeaders.ts +++ b/open-sse/services/antigravityHeaders.ts @@ -42,6 +42,8 @@ function getPlatform(): string { switch (p) { case "win32": return "windows"; + case "darwin": + return "macos"; default: return p; // "linux", etc. } diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 9bcedc2117..0497f9f45e 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -255,7 +255,14 @@ async function validateResponseQuality( return { valid: false, reason: "empty content and no tool_calls in response" }; } - return { valid: true }; + return { + valid: true, + clonedResponse: new Response(text, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }), + }; } // In-memory atomic counter per combo for round-robin distribution @@ -1628,7 +1635,7 @@ export async function handleComboChat({ } } - return result; + return quality.clonedResponse ?? result; } // Extract error info from response diff --git a/open-sse/utils/bypassHandler.ts b/open-sse/utils/bypassHandler.ts index 657c7e1dff..4e1ba7b527 100644 --- a/open-sse/utils/bypassHandler.ts +++ b/open-sse/utils/bypassHandler.ts @@ -1,4 +1,4 @@ -import { getCorsOrigin } from "./cors.ts"; +import { CORS_HEADERS } from "./cors.ts"; import { detectFormat } from "../services/provider.ts"; import { translateResponse, initState } from "../translator/index.ts"; import { FORMATS } from "../translator/formats.ts"; @@ -124,7 +124,6 @@ function createNonStreamingResponse(sourceFormat, model) { response: new Response(JSON.stringify(openaiResponse), { headers: { "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), }, }), }; @@ -158,7 +157,6 @@ function createNonStreamingResponse(sourceFormat, model) { response: new Response(JSON.stringify(finalResponse), { headers: { "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), }, }), }; @@ -206,7 +204,6 @@ function createStreamingResponse(sourceFormat, model) { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", - "Access-Control-Allow-Origin": getCorsOrigin(), }, }), }; diff --git a/open-sse/utils/cors.ts b/open-sse/utils/cors.ts index cae4a710c1..8429185c33 100644 --- a/open-sse/utils/cors.ts +++ b/open-sse/utils/cors.ts @@ -1,22 +1,13 @@ /** - * CORS configuration for open-sse handlers. + * Static CORS headers for open-sse handlers. * - * Reads `CORS_ORIGIN` env var (default: "*") so that all handlers - * use the same configurable origin. Equivalent to src/shared/utils/cors.ts - * for the open-sse package boundary. + * `Access-Control-Allow-Origin` is set by the Next.js middleware + * (`src/server/cors/origins.ts`). Handlers in this package only need the + * methods/headers list; the middleware overlays the allowed origin per + * the central allowlist on the way out. */ - -const CORS_ORIGIN = process.env.CORS_ORIGIN || "*"; - export const CORS_HEADERS: Record = { - "Access-Control-Allow-Origin": CORS_ORIGIN, - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, x-api-key, anthropic-version", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS", + "Access-Control-Allow-Headers": + "Content-Type, Authorization, x-api-key, anthropic-version, x-omniroute-connection, x-internal-test, accept", }; - -/** - * Returns just the origin header for merging into existing header objects. - */ -export function getCorsOrigin(): string { - return CORS_ORIGIN; -} diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index a5379a3f06..95b139498f 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -1,4 +1,4 @@ -import { getCorsOrigin } from "./cors.ts"; +import { CORS_HEADERS } from "./cors.ts"; import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts"; import { normalizePayloadForLog } from "@/lib/logPayloads"; import type { ModelCooldownErrorPayload } from "@/types"; @@ -32,7 +32,6 @@ export function errorResponse(statusCode, message) { status: statusCode, headers: { "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), }, }); } @@ -258,7 +257,6 @@ export function providerCircuitOpenResponse( status: 503, headers: { "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), "Retry-After": String(retryAfterSec), "X-OmniRoute-Provider-Breaker": "open", }, @@ -307,7 +305,6 @@ export function modelCooldownResponse({ status: 429, headers: { "Content-Type": "application/json", - "Access-Control-Allow-Origin": getCorsOrigin(), "Retry-After": String(retryAfterSec), }, } diff --git a/open-sse/utils/ollamaTransform.ts b/open-sse/utils/ollamaTransform.ts index c6f22335e3..b2d86d9fac 100644 --- a/open-sse/utils/ollamaTransform.ts +++ b/open-sse/utils/ollamaTransform.ts @@ -1,4 +1,4 @@ -import { getCorsOrigin } from "./cors.ts"; +import { CORS_HEADERS } from "./cors.ts"; type PendingToolCall = { id?: string; @@ -105,7 +105,6 @@ export function transformToOllama(response, model) { return new Response(response.body.pipeThrough(transform), { headers: { "Content-Type": "application/x-ndjson", - "Access-Control-Allow-Origin": getCorsOrigin(), }, }); } diff --git a/scripts/smoke-electron-packaged.mjs b/scripts/smoke-electron-packaged.mjs index 40ef6b37ef..f7baa136c7 100644 --- a/scripts/smoke-electron-packaged.mjs +++ b/scripts/smoke-electron-packaged.mjs @@ -323,12 +323,22 @@ export function buildSmokeEnv({ smokeEnv.TMPDIR ||= join(dataDir, "tmp"); } - return { + const baseEnv = { ...smokeEnv, DATA_DIR: dataDir, ELECTRON_ENABLE_LOGGING: "1", ELECTRON_ENABLE_STACK_DUMPING: "1", }; + + // CI environments need sandbox disabled (GitHub Actions runners + // cannot configure SUID chrome-sandbox on Linux, and Windows + // runners may exit silently without it). + if (parentEnv.CI) { + baseEnv.CI = parentEnv.CI; + baseEnv.ELECTRON_DISABLE_SANDBOX = "1"; + } + + return baseEnv; } function isInsideDir(parentDir, candidateDir) { @@ -357,6 +367,15 @@ async function ensureSmokeEnvDirs(smokeEnv, dataDir) { ), ]; + // On Windows, Electron derives its userData from APPDATA/. + // requestSingleInstanceLock() runs synchronously at module load and + // fails silently if the directory doesn't exist yet — causing exit(0). + if (platform() === "win32" && smokeEnv.APPDATA) { + for (const subdir of ["omniroute-desktop", "OmniRoute", "omniroute"]) { + dirs.push(join(smokeEnv.APPDATA, subdir)); + } + } + await Promise.all(dirs.map((dir) => mkdir(dir, { recursive: true }))); } @@ -398,13 +417,25 @@ async function main() { await assertPortIsFree(smokeUrl); await ensureSmokeEnvDirs(smokeEnv, dataDir); + // ── CI sandbox workaround ────────────────────────────────── + // GitHub Actions runners cannot set SUID on chrome-sandbox (Linux) + // and Windows runners may fail silently without --no-sandbox. + const spawnArgs = []; + if (process.env.CI) { + spawnArgs.push("--no-sandbox", "--disable-gpu"); + if (platform() === "linux") { + spawnArgs.push("--disable-dev-shm-usage"); + } + } + console.log(`[electron-smoke] launching ${appExecutable}`); + if (spawnArgs.length) console.log(`[electron-smoke] CI args: ${spawnArgs.join(" ")}`); console.log(`[electron-smoke] DATA_DIR=${dataDir}`); console.log(`[electron-smoke] waiting for ${smokeUrl}`); const logs = { value: "" }; const streamLogs = process.env.ELECTRON_SMOKE_STREAM_LOGS === "1"; - const child = spawn(appExecutable, [], { + const child = spawn(appExecutable, spawnArgs, { detached: platform() !== "win32", env: smokeEnv, stdio: ["ignore", "pipe", "pipe"], diff --git a/src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx b/src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx index 5583361467..ff5e85de83 100644 --- a/src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx @@ -184,21 +184,45 @@ export default function SecurityTab() {

{t("apiEndpointProtection")}

- {/* Require auth for /models */} +
+

{t("authModelHeading")}

+
    +
  • {t("authModelClient")}
  • +
  • {t("authModelManagement")}
  • +
  • {t("authModelPublic")}
  • +
+
+
-

{t("requireAuthModels")}

-

{t("requireAuthModelsDesc")}

+

{t("bruteForceProtection")}

+

{t("bruteForceProtectionDesc")}

updateSetting("requireAuthForModels", !settings.requireAuthForModels)} + checked={settings.bruteForceProtection !== false} + onChange={() => + updateSetting("bruteForceProtection", !(settings.bruteForceProtection !== false)) + } disabled={loading} />
+
+
+

{t("corsAllowedOrigins")}

+

{t("corsAllowedOriginsDesc")}

+
+ setSettings((prev) => ({ ...prev, corsOrigins: e.target.value }))} + onBlur={(e) => updateSetting("corsOrigins", e.target.value.trim())} + /> +
+ {/* Blocked Providers */} -
+

{t("blockedProviders")}

{t("blockedProvidersDesc")}

diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 8fda3a2072..b5dbf270d0 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -10,6 +10,11 @@ import { } from "@/lib/auth/managementPassword"; import { loginSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { + checkLoginGuard, + clearLoginAttempts, + recordLoginFailure, +} from "@/server/auth/loginGuard"; // SECURITY: No hardcoded fallback — JWT_SECRET must be configured. if (!process.env.JWT_SECRET) { @@ -59,6 +64,32 @@ export async function POST(request) { return NextResponse.json({ error: "Invalid password payload" }, { status: 400 }); } const settings = await getSettings(); + const bruteForceEnabled = settings.bruteForceProtection !== false; + const clientIp = auditContext.ipAddress || null; + + const guardCheck = checkLoginGuard(clientIp, { enabled: bruteForceEnabled }); + if (!guardCheck.allowed) { + logAuditEvent({ + action: "auth.login.locked", + actor: "anonymous", + target: "dashboard-auth", + resourceType: "auth_session", + status: "failed", + ipAddress: clientIp || undefined, + requestId: auditContext.requestId, + metadata: { retryAfterSeconds: guardCheck.retryAfterSeconds || 0 }, + }); + return NextResponse.json( + { error: "Too many failed attempts. Try again later." }, + { + status: 429, + headers: guardCheck.retryAfterSeconds + ? { "Retry-After": String(guardCheck.retryAfterSeconds) } + : {}, + } + ); + } + const passwordState = await ensurePersistentManagementPasswordHash({ settings, source: "auth.login", @@ -119,9 +150,12 @@ export async function POST(request) { }, }); + clearLoginAttempts(clientIp); return NextResponse.json({ success: true }); } + const failureDecision = recordLoginFailure(clientIp, { enabled: bruteForceEnabled }); + logAuditEvent({ action: "auth.login.failed", actor: "anonymous", @@ -130,8 +164,21 @@ export async function POST(request) { status: "failed", ipAddress: auditContext.ipAddress || undefined, requestId: auditContext.requestId, - metadata: { reason: "invalid_password" }, + metadata: { reason: "invalid_password", lockedOut: failureDecision.allowed === false }, }); + + if (!failureDecision.allowed) { + return NextResponse.json( + { error: "Too many failed attempts. Try again later." }, + { + status: 429, + headers: failureDecision.retryAfterSeconds + ? { "Retry-After": String(failureDecision.retryAfterSeconds) } + : {}, + } + ); + } + return NextResponse.json({ error: "Invalid password" }, { status: 401 }); } catch (error) { console.error("[AUTH] Login failed:", error); diff --git a/src/app/api/combos/test/route.ts b/src/app/api/combos/test/route.ts index 115061f38b..5624b94ac7 100644 --- a/src/app/api/combos/test/route.ts +++ b/src/app/api/combos/test/route.ts @@ -1,12 +1,24 @@ import { randomUUID } from "node:crypto"; import { NextResponse } from "next/server"; import { buildComboTestRequestBody, extractComboTestResponseText } from "@/lib/combos/testHealth"; -import { getComboByName, getCombos } from "@/lib/localDb"; +import { getApiKeys, getComboByName, getCombos } from "@/lib/localDb"; import { resolveNestedComboTargets } from "@omniroute/open-sse/services/combo.ts"; import { testComboSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +async function getInternalApiKey(): Promise { + try { + const keys = await getApiKeys(); + const active = ( + keys as Array<{ key: string; isActive?: boolean; revokedAt?: string | null }> + ).find((k) => k.key && k.isActive !== false && !k.revokedAt); + return active?.key ?? null; + } catch { + return null; + } +} + function buildComboTestResult(target, partial = {}) { return { model: target.modelStr, @@ -19,7 +31,7 @@ function buildComboTestResult(target, partial = {}) { }; } -async function testComboTarget(target, baseInternalUrl) { +async function testComboTarget(target, baseInternalUrl, internalApiKey: string | null) { const startTime = Date.now(); try { const isEmbedding = @@ -38,8 +50,7 @@ async function testComboTarget(target, baseInternalUrl) { method: "POST", headers: { "Content-Type": "application/json", - // Internal dashboard tests still use the normal /v1 pipeline but - // bypass REQUIRE_API_KEY so admins can test with local session auth. + ...(internalApiKey ? { Authorization: `Bearer ${internalApiKey}` } : {}), "X-Internal-Test": "combo-health-check", // Force a fresh execution path so combo tests cannot be satisfied by // OmniRoute's semantic cache or other request reuse layers. @@ -145,8 +156,9 @@ export async function POST(request) { } const baseInternalUrl = getBaseUrl(request); + const internalApiKey = await getInternalApiKey(); const results = await Promise.all( - targets.map((target) => testComboTarget(target, baseInternalUrl)) + targets.map((target) => testComboTarget(target, baseInternalUrl, internalApiKey)) ); const resolvedResult = results.find((result) => result.status === "ok") || null; const resolvedBy = resolvedResult?.model || null; diff --git a/src/app/api/keys/[id]/reveal/route.ts b/src/app/api/keys/[id]/reveal/route.ts index d3d6545bae..489fa5e9cf 100644 --- a/src/app/api/keys/[id]/reveal/route.ts +++ b/src/app/api/keys/[id]/reveal/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getApiKeyById } from "@/lib/localDb"; import { isApiKeyRevealEnabled } from "@/lib/apiKeyExposure"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import * as log from "@/sse/utils/logger"; // GET /api/keys/[id]/reveal - Reveal full API key for explicit copy actions export async function GET(request, { params }) { @@ -22,7 +23,7 @@ export async function GET(request, { params }) { return NextResponse.json({ key: key.key }); } catch (error) { - console.log("Error revealing key:", error); + log.error("keys", "Error revealing key", error); return NextResponse.json({ error: "Failed to reveal key" }, { status: 500 }); } } diff --git a/src/app/api/keys/[id]/route.ts b/src/app/api/keys/[id]/route.ts index 493cc0094d..dd45d8b298 100644 --- a/src/app/api/keys/[id]/route.ts +++ b/src/app/api/keys/[id]/route.ts @@ -10,6 +10,7 @@ import { syncToCloud } from "@/lib/cloudSync"; import { updateKeyPermissionsSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import * as log from "@/sse/utils/logger"; // GET /api/keys/[id] - Get single API key export async function GET(request, { params }) { @@ -31,7 +32,7 @@ export async function GET(request, { params }) { key: keyValue ? keyValue.slice(0, 8) + "****" + keyValue.slice(-4) : null, }); } catch (error) { - console.log("Error fetching key:", error); + log.error("keys", "Error fetching key", error); return NextResponse.json({ error: "Failed to fetch key" }, { status: 500 }); } } @@ -103,7 +104,7 @@ export async function PATCH(request, { params }) { ...(accessSchedule !== undefined && { accessSchedule }), }); } catch (error) { - console.log("Error updating key permissions:", error); + log.error("keys", "Error updating key permissions", error); return NextResponse.json({ error: "Failed to update permissions" }, { status: 500 }); } } @@ -126,7 +127,7 @@ export async function DELETE(request, { params }) { return NextResponse.json({ message: "Key deleted successfully" }); } catch (error) { - console.log("Error deleting key:", error); + log.error("keys", "Error deleting key", error); return NextResponse.json({ error: "Failed to delete key" }, { status: 500 }); } } @@ -142,6 +143,6 @@ async function syncKeysToCloudIfEnabled() { const machineId = await getConsistentMachineId(); await syncToCloud(machineId); } catch (error) { - console.log("Error syncing keys to cloud:", error); + log.error("keys", "Error syncing keys to cloud", error); } } diff --git a/src/app/api/keys/route.ts b/src/app/api/keys/route.ts index d401cc9766..533c433749 100644 --- a/src/app/api/keys/route.ts +++ b/src/app/api/keys/route.ts @@ -6,6 +6,7 @@ import { createKeySchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { isApiKeyRevealEnabled, maskStoredApiKey } from "@/lib/apiKeyExposure"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import * as log from "@/sse/utils/logger"; function parsePagination(request: Request) { const url = new URL(request.url); @@ -43,7 +44,7 @@ export async function GET(request: Request) { allowKeyReveal: isApiKeyRevealEnabled(), }); } catch (error) { - console.log("Error fetching keys:", error); + log.error("keys", "Error fetching keys", error); return NextResponse.json({ error: "Failed to fetch keys" }, { status: 500 }); } } @@ -84,7 +85,7 @@ export async function POST(request) { { status: 201 } ); } catch (error) { - console.log("Error creating key:", error); + log.error("keys", "Error creating key", error); return NextResponse.json({ error: "Failed to create key" }, { status: 500 }); } } @@ -100,6 +101,6 @@ async function syncKeysToCloudIfEnabled() { const machineId = await getConsistentMachineId(); await syncToCloud(machineId); } catch (error) { - console.log("Error syncing keys to cloud:", error); + log.error("keys", "Error syncing keys to cloud", error); } } diff --git a/src/app/api/v1/_helpers/apiKeyScope.ts b/src/app/api/v1/_helpers/apiKeyScope.ts index cce0f76a75..f8fb466705 100644 --- a/src/app/api/v1/_helpers/apiKeyScope.ts +++ b/src/app/api/v1/_helpers/apiKeyScope.ts @@ -1,8 +1,5 @@ -import { NextResponse } from "next/server"; - import { getApiKeyMetadata } from "@/lib/localDb"; -import { CORS_HEADERS } from "@/shared/utils/cors"; -import { extractApiKey, isValidApiKey } from "@/sse/services/auth"; +import { extractApiKey } from "@/sse/services/auth"; export interface ApiKeyRequestScope { apiKey: string | null; @@ -13,43 +10,8 @@ export interface ApiKeyRequestScope { export async function getApiKeyRequestScope(request: Request): Promise { const apiKey = extractApiKey(request); - - if (process.env.REQUIRE_API_KEY === "true") { - if (!apiKey) { - return { - apiKey: null, - apiKeyId: null, - apiKeyMetadata: null, - rejection: NextResponse.json( - { error: { message: "Missing API key", type: "invalid_request_error" } }, - { status: 401, headers: CORS_HEADERS } - ), - }; - } - - if (!(await isValidApiKey(apiKey))) { - return { - apiKey: null, - apiKeyId: null, - apiKeyMetadata: null, - rejection: NextResponse.json( - { error: { message: "Invalid API key", type: "invalid_request_error" } }, - { status: 401, headers: CORS_HEADERS } - ), - }; - } - } - - if (apiKey && !(await isValidApiKey(apiKey))) { - return { - apiKey: null, - apiKeyId: null, - apiKeyMetadata: null, - rejection: NextResponse.json( - { error: { message: "Invalid API key", type: "invalid_request_error" } }, - { status: 401, headers: CORS_HEADERS } - ), - }; + if (!apiKey) { + return { apiKey: null, apiKeyId: null, apiKeyMetadata: null, rejection: null }; } const apiKeyMetadata = await getApiKeyMetadata(apiKey); diff --git a/src/app/api/v1/api/chat/route.ts b/src/app/api/v1/api/chat/route.ts index 5ac4feae8d..c531911121 100644 --- a/src/app/api/v1/api/chat/route.ts +++ b/src/app/api/v1/api/chat/route.ts @@ -1,4 +1,3 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; import { transformToOllama } from "@omniroute/open-sse/utils/ollamaTransform.ts"; @@ -16,7 +15,6 @@ async function ensureInitialized() { export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "*", }, diff --git a/src/app/api/v1/audio/speech/route.ts b/src/app/api/v1/audio/speech/route.ts index 1fec03c3c8..32f32da974 100644 --- a/src/app/api/v1/audio/speech/route.ts +++ b/src/app/api/v1/audio/speech/route.ts @@ -1,11 +1,5 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { handleAudioSpeech } from "@omniroute/open-sse/handlers/audioSpeech.ts"; -import { - getProviderCredentials, - clearRecoveredProviderState, - extractApiKey, - isValidApiKey, -} from "@/sse/services/auth"; +import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth"; import { parseSpeechModel, getSpeechProvider, @@ -25,7 +19,6 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "*", }, @@ -37,13 +30,6 @@ export async function OPTIONS() { * OpenAI TTS API compatible. Returns audio stream. */ export async function POST(request) { - if (process.env.REQUIRE_API_KEY === "true") { - const apiKey = extractApiKey(request); - if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); - const valid = await isValidApiKey(apiKey); - if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); - } - let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/v1/audio/transcriptions/route.ts b/src/app/api/v1/audio/transcriptions/route.ts index c6a206c36f..69fa331614 100644 --- a/src/app/api/v1/audio/transcriptions/route.ts +++ b/src/app/api/v1/audio/transcriptions/route.ts @@ -1,14 +1,7 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; - // Allow large audio/video file uploads — 5min for processing large files (up to 2GB) export const maxDuration = 300; import { handleAudioTranscription } from "@omniroute/open-sse/handlers/audioTranscription.ts"; -import { - getProviderCredentials, - clearRecoveredProviderState, - extractApiKey, - isValidApiKey, -} from "@/sse/services/auth"; +import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth"; import { parseTranscriptionModel, getTranscriptionProvider, @@ -26,7 +19,6 @@ import { getProviderNodes } from "@/lib/localDb"; export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "*", }, @@ -38,14 +30,6 @@ export async function OPTIONS() { * OpenAI Whisper API compatible (multipart/form-data) */ export async function POST(request) { - // Optional API key validation - if (process.env.REQUIRE_API_KEY === "true") { - const apiKey = extractApiKey(request); - if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); - const valid = await isValidApiKey(apiKey); - if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); - } - let formData; try { formData = await request.formData(); diff --git a/src/app/api/v1/chat/completions/route.ts b/src/app/api/v1/chat/completions/route.ts index 3508d97f70..c161f0cac2 100644 --- a/src/app/api/v1/chat/completions/route.ts +++ b/src/app/api/v1/chat/completions/route.ts @@ -1,4 +1,4 @@ -import { CORS_ORIGIN, CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { callCloudWithMachineId } from "@/shared/utils/cloud"; import { handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; @@ -38,9 +38,7 @@ export async function POST(request) { const ct = request.headers.get("content-type") ?? ""; const cl = request.headers.get("content-length"); if (cl && Number(cl) > 256 * 1024) { - console.error( - `[CHAT-ROUTE] large body content-type="${ct}" content-length=${cl}` - ); + console.error(`[CHAT-ROUTE] large body content-type="${ct}" content-length=${cl}`); } } diff --git a/src/app/api/v1/completions/route.ts b/src/app/api/v1/completions/route.ts index 449fb2f9e6..a146e46c1b 100644 --- a/src/app/api/v1/completions/route.ts +++ b/src/app/api/v1/completions/route.ts @@ -1,4 +1,4 @@ -import { CORS_ORIGIN, CORS_HEADERS } from "@/shared/utils/cors"; +import { CORS_HEADERS } from "@/shared/utils/cors"; import { buildClientRawRequest, handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; import { createInjectionGuard } from "@/middleware/promptInjectionGuard"; @@ -21,7 +21,6 @@ function ensureInitialized() { export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "*", }, diff --git a/src/app/api/v1/embeddings/route.ts b/src/app/api/v1/embeddings/route.ts index 6045cb31d7..9992f8273e 100644 --- a/src/app/api/v1/embeddings/route.ts +++ b/src/app/api/v1/embeddings/route.ts @@ -1,4 +1,3 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { handleEmbedding } from "@omniroute/open-sse/handlers/embeddings.ts"; import { getProviderCredentials, @@ -30,7 +29,6 @@ import { getAllCustomModels, getProviderNodes } from "@/lib/localDb"; export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "*", }, @@ -232,18 +230,6 @@ export async function POST(request) { } const body = validation.data; - // Optional API key validation - if (process.env.REQUIRE_API_KEY === "true") { - const apiKey = extractApiKey(request); - if (!apiKey) { - return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); - } - const valid = await isValidApiKey(apiKey); - if (!valid) { - return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); - } - } - // Enforce API key policies (model restrictions + budget limits) const policy = await enforceApiKeyPolicy(request, body.model); if (policy.rejection) return policy.rejection; diff --git a/src/app/api/v1/images/edits/route.ts b/src/app/api/v1/images/edits/route.ts index 05f70e3ceb..b3b4751cfa 100644 --- a/src/app/api/v1/images/edits/route.ts +++ b/src/app/api/v1/images/edits/route.ts @@ -1,4 +1,3 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { handleImageEdit } from "@omniroute/open-sse/handlers/imageGeneration.ts"; import { getProviderCredentials, @@ -31,7 +30,6 @@ import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy"; export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "*", }, @@ -84,7 +82,10 @@ export async function POST(request: Request) { try { formData = await request.formData(); } catch (err) { - log.warn("IMAGE", `Invalid multipart body: ${err instanceof Error ? err.message : String(err)}`); + log.warn( + "IMAGE", + `Invalid multipart body: ${err instanceof Error ? err.message : String(err)}` + ); return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid multipart body"); } @@ -127,7 +128,10 @@ export async function POST(request: Request) { const credentials = await getProviderCredentials(parsed.provider, apiKey); if (!credentials) { - return errorResponse(HTTP_STATUS.UNAUTHORIZED, `No credentials for provider: ${parsed.provider}`); + return errorResponse( + HTTP_STATUS.UNAUTHORIZED, + `No credentials for provider: ${parsed.provider}` + ); } if (credentials.allRateLimited) { return unavailableResponse( @@ -163,10 +167,7 @@ export async function POST(request: Request) { }); } - const errorPayload = toJsonErrorPayload( - (result as any).error, - "Image edit provider error" - ); + const errorPayload = toJsonErrorPayload((result as any).error, "Image edit provider error"); return new Response(JSON.stringify(errorPayload), { status: (result as any).status, headers: { "Content-Type": "application/json" }, diff --git a/src/app/api/v1/images/generations/route.ts b/src/app/api/v1/images/generations/route.ts index be7aa2b3b6..5c051da1c0 100644 --- a/src/app/api/v1/images/generations/route.ts +++ b/src/app/api/v1/images/generations/route.ts @@ -1,4 +1,3 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts"; import { getProviderCredentials, @@ -28,7 +27,6 @@ import { getAllCustomModels } from "@/lib/localDb"; export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "*", }, @@ -133,18 +131,6 @@ export async function POST(request) { } const body = validation.data; - // Optional API key validation - if (process.env.REQUIRE_API_KEY === "true") { - const apiKey = extractApiKey(request); - if (!apiKey) { - return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); - } - const valid = await isValidApiKey(apiKey); - if (!valid) { - return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); - } - } - // Enforce API key policies (model restrictions + budget limits) const policy = await enforceApiKeyPolicy(request, body.model); if (policy.rejection) return policy.rejection; diff --git a/src/app/api/v1/messages/count_tokens/route.ts b/src/app/api/v1/messages/count_tokens/route.ts index 3362654906..1846478e41 100644 --- a/src/app/api/v1/messages/count_tokens/route.ts +++ b/src/app/api/v1/messages/count_tokens/route.ts @@ -38,23 +38,6 @@ export async function POST(request) { } const body = validation.data; - if (process.env.REQUIRE_API_KEY === "true") { - const apiKey = extractApiKey(request); - if (!apiKey) { - return new Response(JSON.stringify({ error: "Missing API key" }), { - status: 401, - headers: { "Content-Type": "application/json", ...CORS_HEADERS }, - }); - } - const valid = await isValidApiKey(apiKey); - if (!valid) { - return new Response(JSON.stringify({ error: "Invalid API key" }), { - status: 401, - headers: { "Content-Type": "application/json", ...CORS_HEADERS }, - }); - } - } - const estimated = buildEstimatedCountResponse(body); const requestedModel = typeof body.model === "string" ? body.model : ""; if (!requestedModel) { diff --git a/src/app/api/v1/messages/route.ts b/src/app/api/v1/messages/route.ts index 90c39534d7..274f0aecce 100644 --- a/src/app/api/v1/messages/route.ts +++ b/src/app/api/v1/messages/route.ts @@ -1,4 +1,3 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; @@ -21,7 +20,6 @@ async function ensureInitialized() { export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "*", }, diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 633e2da26c..6bc34f90bf 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -1,4 +1,3 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; import { AI_PROVIDERS } from "@/shared/constants/providers"; import { @@ -9,7 +8,6 @@ import { getProviderNodes, getModelIsHidden, } from "@/lib/localDb"; -import { isAuthenticated } from "@/shared/utils/apiAuth"; import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry.ts"; import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry.ts"; import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry.ts"; @@ -142,39 +140,14 @@ function buildAliasMaps() { */ export async function getUnifiedModelsResponse( request: Request, - corsHeaders: Record = { - "Access-Control-Allow-Origin": CORS_ORIGIN, - } + corsHeaders: Record = {} ) { const diagnosticHeaders = getCatalogDiagnosticsHeaders({ request }); try { - // Issue #100: Optionally require authentication for /models (security hardening) - // When enabled, unauthenticated requests get 401 with proper error response. - // Supports API key (Bearer token) for external clients and JWT cookie for dashboard. let settings: Record = {}; try { settings = await getSettings(); } catch {} - if (settings.requireAuthForModels === true) { - if (!(await isAuthenticated(request))) { - return Response.json( - { - error: { - message: "Authentication required", - type: "invalid_request_error", - code: "invalid_api_key", - }, - }, - { - status: 401, - headers: { - ...corsHeaders, - ...diagnosticHeaders, - }, - } - ); - } - } const { aliasToProviderId, providerIdToAlias } = buildAliasMaps(); diff --git a/src/app/api/v1/models/route.ts b/src/app/api/v1/models/route.ts index 2224b13ee6..eb30a4daf7 100644 --- a/src/app/api/v1/models/route.ts +++ b/src/app/api/v1/models/route.ts @@ -1,4 +1,3 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { getUnifiedModelsResponse } from "./catalog"; /** @@ -7,7 +6,6 @@ import { getUnifiedModelsResponse } from "./catalog"; export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "*", }, diff --git a/src/app/api/v1/moderations/route.ts b/src/app/api/v1/moderations/route.ts index 8b407c0a5e..5d7881b533 100644 --- a/src/app/api/v1/moderations/route.ts +++ b/src/app/api/v1/moderations/route.ts @@ -1,11 +1,5 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { handleModeration } from "@omniroute/open-sse/handlers/moderations.ts"; -import { - getProviderCredentials, - clearRecoveredProviderState, - extractApiKey, - isValidApiKey, -} from "@/sse/services/auth"; +import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth"; import { parseModerationModel } from "@omniroute/open-sse/config/moderationRegistry.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; @@ -19,7 +13,6 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "*", }, @@ -31,13 +24,6 @@ export async function OPTIONS() { * OpenAI Moderations API compatible. */ export async function POST(request) { - if (process.env.REQUIRE_API_KEY === "true") { - const apiKey = extractApiKey(request); - if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); - const valid = await isValidApiKey(apiKey); - if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); - } - let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/v1/music/generations/route.ts b/src/app/api/v1/music/generations/route.ts index 3dce64136b..1199f68f3f 100644 --- a/src/app/api/v1/music/generations/route.ts +++ b/src/app/api/v1/music/generations/route.ts @@ -1,4 +1,3 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { handleMusicGeneration } from "@omniroute/open-sse/handlers/musicGeneration.ts"; import { getProviderCredentials, @@ -25,7 +24,6 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "*", }, @@ -76,18 +74,6 @@ export async function POST(request) { return errorResponse(HTTP_STATUS.BAD_REQUEST, "Prompt is required"); } - // Optional API key validation - if (process.env.REQUIRE_API_KEY === "true") { - const apiKey = extractApiKey(request); - if (!apiKey) { - return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); - } - const valid = await isValidApiKey(apiKey); - if (!valid) { - return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); - } - } - // Enforce API key policies (model restrictions + budget limits) const policy = await enforceApiKeyPolicy(request, body.model); if (policy.rejection) return policy.rejection; diff --git a/src/app/api/v1/providers/[provider]/chat/completions/route.ts b/src/app/api/v1/providers/[provider]/chat/completions/route.ts index 8bf8924922..20b2e15c81 100644 --- a/src/app/api/v1/providers/[provider]/chat/completions/route.ts +++ b/src/app/api/v1/providers/[provider]/chat/completions/route.ts @@ -1,4 +1,3 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { buildClientRawRequest, handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; @@ -22,7 +21,6 @@ async function ensureInitialized() { export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "*", }, diff --git a/src/app/api/v1/providers/[provider]/embeddings/route.ts b/src/app/api/v1/providers/[provider]/embeddings/route.ts index d4051de55f..f5329addb6 100644 --- a/src/app/api/v1/providers/[provider]/embeddings/route.ts +++ b/src/app/api/v1/providers/[provider]/embeddings/route.ts @@ -1,4 +1,3 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; @@ -20,7 +19,6 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "*", }, @@ -53,14 +51,6 @@ export async function POST(request, { params }) { } const body = validation.data; - // Optional API key validation - if (process.env.REQUIRE_API_KEY === "true") { - const apiKey = extractApiKey(request); - if (!apiKey || !(await isValidApiKey(apiKey))) { - return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); - } - } - // Add provider prefix if missing if (body.model && !body.model.includes("/")) { body.model = `${providerAlias}/${body.model}`; diff --git a/src/app/api/v1/providers/[provider]/images/generations/route.ts b/src/app/api/v1/providers/[provider]/images/generations/route.ts index fd0e79dc60..66555796e0 100644 --- a/src/app/api/v1/providers/[provider]/images/generations/route.ts +++ b/src/app/api/v1/providers/[provider]/images/generations/route.ts @@ -1,4 +1,3 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts"; import { errorResponse, unavailableResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; @@ -21,7 +20,6 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "*", }, @@ -52,14 +50,6 @@ export async function POST(request, { params }) { } const body = validation.data; - // Optional API key validation - if (process.env.REQUIRE_API_KEY === "true") { - const apiKey = extractApiKey(request); - if (!apiKey || !(await isValidApiKey(apiKey))) { - return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); - } - } - // Ensure model has provider prefix if (!body.model.includes("/")) { body.model = `${rawProvider}/${body.model}`; diff --git a/src/app/api/v1/rerank/route.ts b/src/app/api/v1/rerank/route.ts index 4f3fc8fba4..e5ec4419bb 100644 --- a/src/app/api/v1/rerank/route.ts +++ b/src/app/api/v1/rerank/route.ts @@ -1,11 +1,5 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { handleRerank } from "@omniroute/open-sse/handlers/rerank.ts"; -import { - getProviderCredentials, - clearRecoveredProviderState, - extractApiKey, - isValidApiKey, -} from "@/sse/services/auth"; +import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth"; import { parseRerankModel, getRerankProvider } from "@omniroute/open-sse/config/rerankRegistry.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; @@ -20,7 +14,6 @@ import { getProviderNodes } from "@/lib/localDb"; export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "*", }, @@ -52,14 +45,6 @@ function buildDynamicRerankProvider(node: any) { * and local provider_nodes (oMLX, vLLM, etc.) via dynamic routing. */ export async function POST(request) { - // Optional API key validation - if (process.env.REQUIRE_API_KEY === "true") { - const apiKey = extractApiKey(request); - if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); - const valid = await isValidApiKey(apiKey); - if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); - } - let rawBody; try { rawBody = await request.json(); @@ -174,7 +159,7 @@ export async function POST(request) { const data = await res.json(); return Response.json(data, { - headers: { "Access-Control-Allow-Origin": CORS_ORIGIN }, + headers: {}, }); } catch (err: any) { return errorResponse(500, `Rerank request failed: ${err.message}`); diff --git a/src/app/api/v1/responses/[...path]/route.ts b/src/app/api/v1/responses/[...path]/route.ts index bf967e5d60..e2f7062f5a 100644 --- a/src/app/api/v1/responses/[...path]/route.ts +++ b/src/app/api/v1/responses/[...path]/route.ts @@ -1,4 +1,3 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; @@ -15,7 +14,6 @@ async function ensureInitialized() { export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "*", }, diff --git a/src/app/api/v1/responses/route.ts b/src/app/api/v1/responses/route.ts index 809ed1a41f..d4cfeab444 100644 --- a/src/app/api/v1/responses/route.ts +++ b/src/app/api/v1/responses/route.ts @@ -1,4 +1,3 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { handleChat } from "@/sse/handlers/chat"; // NOTE: We do NOT call initTranslators() here — the translator registry is @@ -13,7 +12,6 @@ import { handleChat } from "@/sse/handlers/chat"; export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "*", }, diff --git a/src/app/api/v1/search/route.ts b/src/app/api/v1/search/route.ts index 8bd8ac87c1..2f282b454a 100644 --- a/src/app/api/v1/search/route.ts +++ b/src/app/api/v1/search/route.ts @@ -1,4 +1,3 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { handleSearch } from "@omniroute/open-sse/handlers/search.ts"; import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth"; import { @@ -24,7 +23,6 @@ import { } from "@omniroute/open-sse/services/searchCache.ts"; const CORS_HEADERS = { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "*", }; @@ -104,18 +102,6 @@ export async function POST(request: Request) { } const body = validation.data; - // Optional API key validation - if (process.env.REQUIRE_API_KEY === "true") { - const apiKey = extractApiKey(request); - if (!apiKey) { - return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); - } - const valid = await isValidApiKey(apiKey); - if (!valid) { - return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); - } - } - // Enforce API key policies — use "search" as model identifier for consistent policy config const policy = await enforceApiKeyPolicy(request, "search"); if (policy.rejection) return policy.rejection; diff --git a/src/app/api/v1/videos/generations/route.ts b/src/app/api/v1/videos/generations/route.ts index b406bd629c..8f961a6a24 100644 --- a/src/app/api/v1/videos/generations/route.ts +++ b/src/app/api/v1/videos/generations/route.ts @@ -1,4 +1,3 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { handleVideoGeneration } from "@omniroute/open-sse/handlers/videoGeneration.ts"; import { getProviderCredentials, @@ -25,7 +24,6 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "*", }, @@ -76,18 +74,6 @@ export async function POST(request) { return errorResponse(HTTP_STATUS.BAD_REQUEST, "Prompt is required"); } - // Optional API key validation - if (process.env.REQUIRE_API_KEY === "true") { - const apiKey = extractApiKey(request); - if (!apiKey) { - return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); - } - const valid = await isValidApiKey(apiKey); - if (!valid) { - return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); - } - } - // Enforce API key policies (model restrictions + budget limits) const policy = await enforceApiKeyPolicy(request, body.model); if (policy.rejection) return policy.rejection; diff --git a/src/app/api/v1beta/models/[...path]/route.ts b/src/app/api/v1beta/models/[...path]/route.ts index 96c76a8417..a8bf6d0230 100644 --- a/src/app/api/v1beta/models/[...path]/route.ts +++ b/src/app/api/v1beta/models/[...path]/route.ts @@ -1,4 +1,3 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { buildClientRawRequest, handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; import { v1betaGeminiGenerateSchema } from "@/shared/validation/schemas"; @@ -23,7 +22,6 @@ async function ensureInitialized() { export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "*", }, diff --git a/src/app/api/v1beta/models/route.ts b/src/app/api/v1beta/models/route.ts index 353f3def42..37390e3f18 100644 --- a/src/app/api/v1beta/models/route.ts +++ b/src/app/api/v1beta/models/route.ts @@ -1,4 +1,3 @@ -import { CORS_ORIGIN } from "@/shared/utils/cors"; import { PROVIDER_MODELS } from "@/shared/constants/models"; import { getAllCustomModels, getSyncedAvailableModels } from "@/lib/db/models"; import { getResolvedModelCapabilities } from "@/lib/modelCapabilities"; @@ -10,7 +9,6 @@ import { getSyncedCapabilities } from "@/lib/modelsDevSync"; export async function OPTIONS() { return new Response(null, { headers: { - "Access-Control-Allow-Origin": CORS_ORIGIN, "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "*", }, diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 5e870fb7fb..303cfe4e23 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3058,6 +3058,14 @@ "apiEndpointProtection": "API Endpoint Protection", "requireAuthModels": "Require API key for /models", "requireAuthModelsDesc": "When ON, the /v1/models endpoint returns 404 for unauthenticated requests. Prevents model discovery by unauthorized users.", + "authModelHeading": "Active authorization model", + "authModelClient": "Client API endpoints (/v1/*, /chat/*, /responses/*, /codex/*, /messages/*) require a Bearer API key.", + "authModelManagement": "Management endpoints (/dashboard, /api/*) require a dashboard session or management credential.", + "authModelPublic": "Only login, health, and bootstrap routes are public.", + "bruteForceProtection": "Login brute-force protection", + "bruteForceProtectionDesc": "Throttle and lock /api/auth/login after repeated failures from the same IP.", + "corsAllowedOrigins": "CORS allowed origins", + "corsAllowedOriginsDesc": "Comma-separated list of browser origins permitted to call this server. Empty list = no browser CORS access (server-to-server still works). Use CORS_ALLOW_ALL=true env var only for development.", "blockedProviders": "Blocked Providers", "blockedProvidersDesc": "Hide specific providers from the /v1/models response. Blocked providers will not appear in model listings.", "providersBlocked": "{count} provider(s) blocked from /models", diff --git a/src/lib/config/runtimeSettings.ts b/src/lib/config/runtimeSettings.ts index 7239790b45..9df6d5985f 100644 --- a/src/lib/config/runtimeSettings.ts +++ b/src/lib/config/runtimeSettings.ts @@ -11,7 +11,8 @@ export type RuntimeReloadSection = | "usageTracking" | "healthCheckLogs" | "thoughtSignature" - | "modelsDevSync"; + | "modelsDevSync" + | "corsOrigins"; export interface RuntimeReloadChange { section: RuntimeReloadSection; @@ -29,6 +30,7 @@ interface RuntimeSettingsSnapshot { hideHealthCheckLogs: boolean; modelsDevSyncEnabled: boolean; modelsDevSyncInterval: number | null; + corsOrigins: string; } const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = { @@ -42,6 +44,7 @@ const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = { hideHealthCheckLogs: false, modelsDevSyncEnabled: false, modelsDevSyncInterval: null, + corsOrigins: "", }; let lastAppliedSnapshot: RuntimeSettingsSnapshot | null = null; @@ -176,6 +179,7 @@ export function buildRuntimeSettingsSnapshot( hideHealthCheckLogs: settings.hideHealthCheckLogs === true, modelsDevSyncEnabled: settings.modelsDevSyncEnabled === true, modelsDevSyncInterval: normalizeNumber(settings.modelsDevSyncInterval), + corsOrigins: typeof settings.corsOrigins === "string" ? settings.corsOrigins : "", }; } @@ -248,6 +252,11 @@ async function applyThoughtSignatureSection(mode: string) { setGeminiThoughtSignatureMode(mode); } +async function applyCorsOriginsSection(corsOrigins: string) { + const { setRuntimeAllowedOrigins } = await import("@/server/cors/origins"); + setRuntimeAllowedOrigins(corsOrigins); +} + async function applyModelsDevSyncSection( previousSnapshot: RuntimeSettingsSnapshot, currentSnapshot: RuntimeSettingsSnapshot, @@ -378,6 +387,11 @@ export async function applyRuntimeSettings( markChanged("modelsDevSync"); } + if (force || hasChanged(currentSnapshot.corsOrigins, previousSnapshot.corsOrigins)) { + await applyCorsOriginsSection(currentSnapshot.corsOrigins); + markChanged("corsOrigins"); + } + lastAppliedSnapshot = currentSnapshot; return changes; } diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 3012ce7aa6..9f3dc41852 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -42,6 +42,11 @@ interface ApiKeyMetadata { maxRequestsPerMinute: number | null; // T08: Per-key max concurrent sticky sessions (0 = unlimited) maxSessions: number; + // Phase 3 lifecycle/policy fields + revokedAt: string | null; + expiresAt: string | null; + ipAllowlist: string[]; + scopes: string[]; } interface ApiKeyRow extends JsonRecord { @@ -97,7 +102,9 @@ interface ApiKeyView extends JsonRecord { // LRU cache for API key validation (valid keys only) const _keyValidationCache = new Map(); const _keyMetadataCache = new Map>(); +const _lastUsedUpdateCache = new Map(); const CACHE_TTL = 60 * 1000; // 1 minute TTL +const LAST_USED_UPDATE_TTL = 5 * 60 * 1000; const MAX_CACHE_SIZE = 1000; // Compiled regex cache for wildcard patterns @@ -121,12 +128,31 @@ function invalidateCaches() { _keyValidationCache.clear(); _keyMetadataCache.clear(); _modelPermissionCache.clear(); + _lastUsedUpdateCache.clear(); } function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" ? (value as JsonRecord) : {}; } +function isConfiguredEnvApiKey(key: string): boolean { + const envKey = process.env.OMNIROUTE_API_KEY || process.env.ROUTER_API_KEY; + return Boolean(envKey && key === envKey); +} + +function markApiKeyUsed(db: ApiKeysDbLike, id: unknown, now: number): void { + if (typeof id !== "string" || id.trim() === "") return; + + const lastUpdate = _lastUsedUpdateCache.get(id); + if (lastUpdate && now - lastUpdate < LAST_USED_UPDATE_TTL) return; + + db.prepare("UPDATE api_keys SET last_used_at = @lastUsedAt WHERE id = @id").run({ + id, + lastUsedAt: new Date(now).toISOString(), + }); + _lastUsedUpdateCache.set(id, now); +} + /** * LRU eviction for cache */ @@ -204,6 +230,32 @@ function ensureApiKeysColumns(db: ApiKeysDbLike) { db.exec("ALTER TABLE api_keys ADD COLUMN max_sessions INTEGER NOT NULL DEFAULT 0"); console.log("[DB] Added api_keys.max_sessions column"); } + // Phase 3: lifecycle / policy columns. Idempotent fallback for DBs whose + // migration 032_apikey_lifecycle.sql did not run yet. + if (!columnNames.has("revoked_at")) { + db.exec("ALTER TABLE api_keys ADD COLUMN revoked_at TEXT"); + console.log("[DB] Added api_keys.revoked_at column"); + } + if (!columnNames.has("expires_at")) { + db.exec("ALTER TABLE api_keys ADD COLUMN expires_at TEXT"); + console.log("[DB] Added api_keys.expires_at column"); + } + if (!columnNames.has("last_used_at")) { + db.exec("ALTER TABLE api_keys ADD COLUMN last_used_at TEXT"); + console.log("[DB] Added api_keys.last_used_at column"); + } + if (!columnNames.has("key_prefix")) { + db.exec("ALTER TABLE api_keys ADD COLUMN key_prefix TEXT"); + console.log("[DB] Added api_keys.key_prefix column"); + } + if (!columnNames.has("ip_allowlist")) { + db.exec("ALTER TABLE api_keys ADD COLUMN ip_allowlist TEXT"); + console.log("[DB] Added api_keys.ip_allowlist column"); + } + if (!columnNames.has("scopes")) { + db.exec("ALTER TABLE api_keys ADD COLUMN scopes TEXT"); + console.log("[DB] Added api_keys.scopes column"); + } _schemaChecked = true; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -227,12 +279,14 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements { ) { _stmtGetAllKeys = db.prepare("SELECT * FROM api_keys ORDER BY created_at"); _stmtGetKeyById = db.prepare("SELECT * FROM api_keys WHERE id = ?"); - _stmtValidateKey = db.prepare("SELECT 1 FROM api_keys WHERE key = ?"); + _stmtValidateKey = db.prepare( + "SELECT id, expires_at, revoked_at, is_active FROM api_keys WHERE key = ?" + ); _stmtGetKeyMetadata = db.prepare( - "SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, max_sessions FROM api_keys WHERE key = ?" + "SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, max_sessions, revoked_at, expires_at, ip_allowlist, scopes FROM api_keys WHERE key = ?" ); _stmtInsertKey = db.prepare( - "INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)" + "INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" ); _stmtDeleteKey = db.prepare("DELETE FROM api_keys WHERE id = ?"); } @@ -373,6 +427,24 @@ function parseAllowedConnections(value: unknown): string[] { } } +function parseStringList(value: unknown): string[] { + if (!value || typeof value !== "string" || value.trim() === "") return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) + ? parsed.filter((entry): entry is string => typeof entry === "string") + : []; + } catch { + return []; + } +} + +function parseNullableTimestamp(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed === "" ? null : trimmed; +} + export async function createApiKey(name: string, machineId: string) { if (!machineId) { throw new Error("machineId is required"); @@ -403,7 +475,8 @@ export async function createApiKey(name: string, machineId: string) { apiKey.machineId, "[]", 0, - apiKey.createdAt + apiKey.createdAt, + apiKey.key.slice(0, 12) ); setNoLog(apiKey.id, false); @@ -566,15 +639,66 @@ export async function deleteApiKey(id: string) { } /** - * Validate API key with caching for performance - * Cached valid keys reduce DB hits on every request + * Revoke an API key by id. Logical, not destructive: the row stays so it can + * be audited, but validateApiKey() rejects it immediately after caches expire + * (or sooner because invalidateCaches() runs here). + */ +export async function revokeApiKey(id: string): Promise { + const db = getDbInstance() as ApiKeysDbLike; + getPreparedStatements(db); + + const result = db + .prepare( + "UPDATE api_keys SET revoked_at = COALESCE(revoked_at, @ts), is_active = 0 WHERE id = @id" + ) + .run({ id, ts: new Date().toISOString() }); + + if ((result.changes ?? 0) === 0) return false; + + invalidateCaches(); + backupDbFile("pre-write"); + return true; +} + +/** + * Set or clear the expiry of an API key. Pass null to remove the expiry. + */ +export async function setApiKeyExpiry(id: string, expiresAt: string | null): Promise { + const db = getDbInstance() as ApiKeysDbLike; + getPreparedStatements(db); + + const result = db + .prepare("UPDATE api_keys SET expires_at = @expiresAt WHERE id = @id") + .run({ id, expiresAt }); + + if ((result.changes ?? 0) === 0) return false; + + invalidateCaches(); + backupDbFile("pre-write"); + return true; +} + +/** + * Validate API key with lifecycle gates and caching. + * + * A key is valid only when ALL of the following are true: + * - the row exists, + * - is_active = 1, + * - revoked_at IS NULL, + * - expires_at IS NULL OR expires_at > now. + * + * Cache TTL is short (CACHE_TTL) and the metadata cache is also invalidated + * by revokeApiKey/updateApiKeyPermissions/deleteApiKey, so a revoke takes + * effect within at most CACHE_TTL even without an explicit clear in the + * caller. */ export async function validateApiKey(key: string | null | undefined) { if (!key || typeof key !== "string") return false; + if (isConfiguredEnvApiKey(key)) return true; + const now = Date.now(); - // Check cache first const cached = _keyValidationCache.get(key); if (cached && now - cached.timestamp < CACHE_TTL) { return cached.valid; @@ -582,16 +706,27 @@ export async function validateApiKey(key: string | null | undefined) { const db = getDbInstance() as ApiKeysDbLike; const stmt = getPreparedStatements(db); - const row = stmt.validateKey.get(key); - const valid = !!row; + const row = stmt.validateKey.get(key) as JsonRecord | undefined; - // Only cache valid keys to prevent cache pollution - if (valid) { - evictIfNeeded(_keyValidationCache); - _keyValidationCache.set(key, { valid: true, timestamp: now }); + if (!row) return false; + + const isActive = parseIsActive(row.is_active ?? row.isActive); + if (!isActive) return false; + + const revokedAt = row.revoked_at ?? row.revokedAt; + if (typeof revokedAt === "string" && revokedAt.trim() !== "") return false; + + const expiresAt = row.expires_at ?? row.expiresAt; + if (typeof expiresAt === "string" && expiresAt.trim() !== "") { + const expiresMs = Date.parse(expiresAt); + if (Number.isFinite(expiresMs) && expiresMs <= now) return false; } - return valid; + evictIfNeeded(_keyValidationCache); + _keyValidationCache.set(key, { valid: true, timestamp: now }); + markApiKeyUsed(db, row.id, now); + + return true; } /** @@ -605,8 +740,7 @@ export async function getApiKeyMetadata( const now = Date.now(); // persistent env-var key support (persistent passthrough keys) (#1350) - const envKey = process.env.OMNIROUTE_API_KEY || process.env.ROUTER_API_KEY; - if (envKey && key === envKey) { + if (isConfiguredEnvApiKey(key)) { return { id: "env-key", name: "Environment Key", @@ -620,6 +754,10 @@ export async function getApiKeyMetadata( maxRequestsPerDay: null, maxRequestsPerMinute: null, maxSessions: 0, + revokedAt: null, + expiresAt: null, + ipAllowlist: [], + scopes: [], }; } @@ -662,6 +800,10 @@ export async function getApiKeyMetadata( maxRequestsPerMinute: typeof rawMaxRPM === "number" && rawMaxRPM > 0 ? rawMaxRPM : null, // T08: max concurrent sessions; 0 = unlimited (default & backward-compatible) maxSessions: typeof rawMaxSessions === "number" && rawMaxSessions > 0 ? rawMaxSessions : 0, + revokedAt: parseNullableTimestamp(record.revoked_at ?? (record as JsonRecord).revokedAt), + expiresAt: parseNullableTimestamp(record.expires_at ?? (record as JsonRecord).expiresAt), + ipAllowlist: parseStringList(record.ip_allowlist ?? (record as JsonRecord).ipAllowlist), + scopes: parseStringList((record as JsonRecord).scopes), }; if (!metadata.id) { @@ -766,6 +908,7 @@ function clearPreparedStatementCache() { */ export function clearApiKeyCaches() { invalidateCaches(); + _lastUsedUpdateCache.clear(); _modelPermissionCache.clear(); _regexCache.clear(); } diff --git a/src/lib/db/migrationRunner.ts b/src/lib/db/migrationRunner.ts index 973f3f7429..18dddca847 100644 --- a/src/lib/db/migrationRunner.ts +++ b/src/lib/db/migrationRunner.ts @@ -156,6 +156,40 @@ function hasTable(db: Database.Database, tableName: string): boolean { return Boolean(row?.name); } +function hasColumn(db: Database.Database, tableName: string, columnName: string): boolean { + const columns = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name?: string }>; + return columns.some((column) => column.name === columnName); +} + +function ensureColumn( + db: Database.Database, + tableName: string, + columnName: string, + ddl: string +): void { + if (!hasColumn(db, tableName, columnName)) { + db.exec(ddl); + } +} + +function isApiKeyLifecycleMigration(migration: { version: string; name: string }): boolean { + return migration.version === "032"; +} + +function applyApiKeyLifecycleMigration(db: Database.Database): void { + ensureColumn(db, "api_keys", "revoked_at", "ALTER TABLE api_keys ADD COLUMN revoked_at TEXT"); + ensureColumn(db, "api_keys", "expires_at", "ALTER TABLE api_keys ADD COLUMN expires_at TEXT"); + ensureColumn(db, "api_keys", "last_used_at", "ALTER TABLE api_keys ADD COLUMN last_used_at TEXT"); + ensureColumn(db, "api_keys", "key_prefix", "ALTER TABLE api_keys ADD COLUMN key_prefix TEXT"); + ensureColumn(db, "api_keys", "ip_allowlist", "ALTER TABLE api_keys ADD COLUMN ip_allowlist TEXT"); + ensureColumn(db, "api_keys", "scopes", "ALTER TABLE api_keys ADD COLUMN scopes TEXT"); + + db.exec(` + CREATE INDEX IF NOT EXISTS idx_api_keys_revoked_at ON api_keys(revoked_at); + CREATE INDEX IF NOT EXISTS idx_api_keys_expires_at ON api_keys(expires_at); + `); +} + function inferPhysicalSchemaBaseline(db: Database.Database): { version: string; description: string; @@ -419,10 +453,13 @@ export function runMigrations(db: Database.Database, options?: { isNewDb?: boole let count = 0; for (const migration of pending) { - const sql = fs.readFileSync(migration.path, "utf-8"); - const applyMigration = db.transaction(() => { - db.exec(sql); + if (isApiKeyLifecycleMigration(migration)) { + applyApiKeyLifecycleMigration(db); + } else { + const sql = fs.readFileSync(migration.path, "utf-8"); + db.exec(sql); + } db.prepare("INSERT INTO _omniroute_migrations (version, name) VALUES (?, ?)").run( migration.version, migration.name diff --git a/src/lib/db/migrations/032_apikey_lifecycle.sql b/src/lib/db/migrations/032_apikey_lifecycle.sql new file mode 100644 index 0000000000..112e5f66d4 --- /dev/null +++ b/src/lib/db/migrations/032_apikey_lifecycle.sql @@ -0,0 +1,24 @@ +-- Migration 032: API Key lifecycle hardening +-- +-- Phase 3 of the unified-authz plan. Adds explicit lifecycle and policy +-- columns to api_keys without touching the existing `key` column. Existing +-- plain-text keys remain valid; key hashing is a separate follow-up step +-- once revocation/expiry are wired through the validator and policy layer. +-- +-- New columns: +-- revoked_at ISO timestamp, NULL when not revoked. +-- expires_at ISO timestamp, NULL = no expiry. +-- last_used_at ISO timestamp updated by validateApiKey on success. +-- key_prefix first ~12 visible chars of the key, for safe display. +-- ip_allowlist JSON array of CIDRs/IPs; NULL or [] = allow any. +-- scopes JSON array of scope strings; NULL or [] = default scopes. + +ALTER TABLE api_keys ADD COLUMN revoked_at TEXT; +ALTER TABLE api_keys ADD COLUMN expires_at TEXT; +ALTER TABLE api_keys ADD COLUMN last_used_at TEXT; +ALTER TABLE api_keys ADD COLUMN key_prefix TEXT; +ALTER TABLE api_keys ADD COLUMN ip_allowlist TEXT; +ALTER TABLE api_keys ADD COLUMN scopes TEXT; + +CREATE INDEX IF NOT EXISTS idx_api_keys_revoked_at ON api_keys(revoked_at); +CREATE INDEX IF NOT EXISTS idx_api_keys_expires_at ON api_keys(expires_at); diff --git a/src/lib/env/runtimeEnv.ts b/src/lib/env/runtimeEnv.ts index c836ddd86d..7b0fe12f52 100644 --- a/src/lib/env/runtimeEnv.ts +++ b/src/lib/env/runtimeEnv.ts @@ -61,7 +61,6 @@ export const webRuntimeEnvSchema = z.object({ API_KEY_SECRET: optionalTrimmedString, INITIAL_PASSWORD: optionalTrimmedString, AUTH_COOKIE_SECURE: optionalBooleanEnv, - REQUIRE_API_KEY: optionalBooleanEnv, PRICING_SYNC_ENABLED: optionalBooleanEnv, OMNIROUTE_DISABLE_BACKGROUND_SERVICES: optionalBooleanEnv, CLOUD_URL: optionalHttpUrl, diff --git a/src/lib/guardrails/visionBridgeHelpers.ts b/src/lib/guardrails/visionBridgeHelpers.ts index d82cdb334e..32fd9bf151 100644 --- a/src/lib/guardrails/visionBridgeHelpers.ts +++ b/src/lib/guardrails/visionBridgeHelpers.ts @@ -1,6 +1,7 @@ /** * Vision Bridge helper functions for image processing. */ +import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; /** * Provider to environment variable mapping for API key resolution. @@ -111,14 +112,9 @@ export function resolveImageAsDataUri(imageUrl: string): string { } async function fetchRemoteImageAsDataUri(imageUrl: string, signal: AbortSignal): Promise { - const response = await fetch(imageUrl, { signal }); - if (!response.ok) { - throw new Error(`Vision image fetch error ${response.status}`); - } - - const mediaType = response.headers.get("content-type")?.split(";")[0]?.trim() || "image/png"; - const bytes = Buffer.from(await response.arrayBuffer()); - return `data:${mediaType};base64,${bytes.toString("base64")}`; + const remoteImage = await fetchRemoteImage(imageUrl, { signal }); + const mediaType = remoteImage.contentType.split(";")[0]?.trim() || "image/png"; + return `data:${mediaType};base64,${remoteImage.buffer.toString("base64")}`; } async function normalizeVisionImageInput( diff --git a/src/proxy.ts b/src/proxy.ts index ffc4f92b7c..cfce41dd47 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,213 +1,22 @@ -import { NextResponse } from "next/server"; -import { jwtVerify, SignJWT } from "jose"; -import { generateRequestId } from "./shared/utils/requestId"; -import { checkBodySize, getBodySizeLimit } from "./shared/middleware/bodySizeGuard"; -import { isDraining } from "./lib/gracefulShutdown"; -import { isPublicApiRoute } from "./shared/constants/publicApiRoutes"; +import type { NextRequest } from "next/server"; +import { runAuthzPipeline } from "./server/authz/pipeline"; -const E2E_MODE = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1"; - -let apiAuthModulePromise: Promise | null = null; -let settingsModulePromise: Promise | null = null; -let modelSyncModulePromise: Promise | null = - null; - -function getJwtSecret(): Uint8Array { - return new TextEncoder().encode(process.env.JWT_SECRET || ""); -} - -async function getApiAuthModule() { - if (!apiAuthModulePromise) { - apiAuthModulePromise = import("./shared/utils/apiAuth"); - } - return apiAuthModulePromise; -} - -async function getSettingsModule() { - if (!settingsModulePromise) { - settingsModulePromise = import("./lib/db/settings"); - } - return settingsModulePromise; -} - -async function getModelSyncModule() { - if (!modelSyncModulePromise) { - modelSyncModulePromise = import("./shared/services/modelSyncScheduler"); - } - return modelSyncModulePromise; -} - -export async function proxy(request: any) { - const { pathname } = request.nextUrl; - - // Pipeline: Add request ID header for end-to-end tracing - const requestId = generateRequestId(); - const response = NextResponse.next(); - response.headers.set("X-Request-Id", requestId); - - // ──────────────── Pre-flight: Reject during shutdown drain ──────────────── - if (isDraining() && pathname.startsWith("/api/")) { - return NextResponse.json( - { - error: { - code: "SERVICE_UNAVAILABLE", - message: "Server is shutting down", - correlation_id: requestId, - }, - }, - { status: 503 } - ); - } - - // ──────────────── Pre-flight: Reject oversized bodies ──────────────── - if (pathname.startsWith("/api/") && request.method !== "GET" && request.method !== "OPTIONS") { - const bodySizeRejection = checkBodySize(request, getBodySizeLimit(pathname)); - if (bodySizeRejection) return bodySizeRejection; - } - - if (E2E_MODE) { - if (pathname.startsWith("/dashboard")) { - return response; - } - if (pathname.startsWith("/api/") && !pathname.startsWith("/api/v1/")) { - return response; - } - } - - // ──────────────── Protect Management API Routes ──────────────── - if (pathname.startsWith("/api/") && !pathname.startsWith("/api/v1/")) { - // Allow public routes (login, logout, health, etc.) - if (isPublicApiRoute(pathname, request.method)) { - return response; - } - - // Allow the model auto-sync scheduler to reach only its internal provider routes. - const { isModelSyncInternalRequest } = await getModelSyncModule(); - if ( - isModelSyncInternalRequest(request) && - /^\/api\/providers\/[^/]+\/(sync-models|models)$/.test(pathname) - ) { - return response; - } - - // Check if auth is required at all (respects requireLogin setting) - const { isAuthRequired, verifyAuth } = await getApiAuthModule(); - const authRequired = await isAuthRequired(); - if (!authRequired) { - return response; - } - - // Verify authentication (JWT cookie or Bearer API key) - const authError = await verifyAuth(request); - if (authError) { - const status = authError === "Invalid management token" ? 403 : 401; - return NextResponse.json( - { - error: { - code: "AUTH_001", - message: authError, - correlation_id: requestId, - }, - }, - { status } - ); - } - } - - // ──────────────── Protect Dashboard Routes ──────────────── - if (pathname.startsWith("/dashboard")) { - // Always allow onboarding — it has its own setupComplete guard - if (pathname.startsWith("/dashboard/onboarding")) { - return response; - } - - try { - // Direct import — no HTTP self-fetch overhead - const { getSettings } = await getSettingsModule(); - const settings = await getSettings(); - // Skip auth if login is not required - if (settings.requireLogin === false) { - return response; - } - // Skip auth ONLY for fresh installs (before onboarding) where no password exists yet. - // Once setupComplete is true, always require auth — prevents bypass if password row is lost (#151) - if (!settings.setupComplete && !settings.password && !process.env.INITIAL_PASSWORD) { - return response; - } - } catch (err) { - // FASE-01: Log settings fetch errors instead of silencing them - console.error("[Middleware] settings_error: Settings read failed:", err.message, { - path: pathname, - requestId, - }); - // On error, require login (fall through to token check) - } - - const token = request.cookies.get("auth_token")?.value; - - if (token) { - try { - const { payload } = await jwtVerify(token, getJwtSecret()); - - // Auto-refresh: if token expires within 7 days, issue a fresh 30-day token - const exp = payload.exp as number; - const now = Math.floor(Date.now() / 1000); - const REFRESH_WINDOW = 7 * 24 * 60 * 60; // 7 days in seconds - if (exp && exp - now < REFRESH_WINDOW) { - try { - const freshToken = await new SignJWT({ authenticated: true }) - .setProtectedHeader({ alg: "HS256" }) - .setExpirationTime("30d") - .sign(getJwtSecret()); - - // Detect secure context - const fwdProto = (request.headers.get("x-forwarded-proto") || "") - .split(",")[0] - .trim() - .toLowerCase(); - const isHttps = fwdProto === "https" || request.nextUrl?.protocol === "https:"; - const useSecure = process.env.AUTH_COOKIE_SECURE === "true" || isHttps; - - response.cookies.set("auth_token", freshToken, { - httpOnly: true, - secure: useSecure, - sameSite: "lax", - path: "/", - }); - console.log( - `[Middleware] JWT auto-refreshed for ${pathname} (was expiring in ${Math.round((exp - now) / 3600)}h)` - ); - } catch (refreshErr) { - // Refresh failed — continue with existing valid token - console.error("[Middleware] JWT auto-refresh failed:", refreshErr.message); - } - } - - return response; - } catch (err) { - // FASE-01: Log auth errors instead of silently redirecting - console.error("[Middleware] auth_error: JWT verification failed:", err.message, { - path: pathname, - tokenPresent: true, - requestId, - }); - const redirectResponse = NextResponse.redirect(new URL("/login", request.url)); - redirectResponse.cookies.delete("auth_token"); - return redirectResponse; - } - } - - return NextResponse.redirect(new URL("/login", request.url)); - } - - // Redirect / to /dashboard if logged in, or /dashboard if it's the root - if (pathname === "/") { - return NextResponse.redirect(new URL("/dashboard", request.url)); - } - - return response; +export async function proxy(request: NextRequest) { + return runAuthzPipeline(request, { enforce: true }); } export const config = { - matcher: ["/", "/dashboard/:path*", "/api/:path*"], + matcher: [ + "/", + "/dashboard/:path*", + "/api/:path*", + "/v1/:path*", + "/v1", + "/chat/:path*", + "/responses/:path*", + "/responses", + "/codex/:path*", + "/codex", + "/models", + ], }; diff --git a/src/server/auth/loginGuard.ts b/src/server/auth/loginGuard.ts new file mode 100644 index 0000000000..392757f647 --- /dev/null +++ b/src/server/auth/loginGuard.ts @@ -0,0 +1,106 @@ +/** + * Login brute-force guard. + * + * Tracks failed `/api/auth/login` attempts per client IP in process memory + * and returns lockout decisions. Single-process scope is intentional — this + * is a defense-in-depth check that pairs with Cloudflare/reverse-proxy rate + * limiting, not a substitute for it. + * + * Tunables: + * - failure threshold: 5 within `WINDOW_MS` + * - lockout duration: `LOCKOUT_MS` + * - sliding window: `WINDOW_MS` + * + * The guard is a no-op when `enabled` is false; the caller decides based on + * the `bruteForceProtection` setting (default true). + */ + +const WINDOW_MS = 15 * 60 * 1000; +const LOCKOUT_MS = 15 * 60 * 1000; +const FAILURE_THRESHOLD = 5; + +interface AttemptState { + count: number; + firstAttemptAt: number; + lockedUntil: number | null; +} + +const attempts: Map = new Map(); + +export interface GuardDecision { + allowed: boolean; + retryAfterSeconds?: number; +} + +function nowMs(): number { + return Date.now(); +} + +function clientKey(rawIp: string | null | undefined): string { + const ip = (rawIp || "").trim(); + return ip || "__unknown__"; +} + +export function checkLoginGuard( + rawIp: string | null | undefined, + options: { enabled: boolean } +): GuardDecision { + if (!options.enabled) return { allowed: true }; + const state = attempts.get(clientKey(rawIp)); + if (!state) return { allowed: true }; + const now = nowMs(); + if (state.lockedUntil && state.lockedUntil > now) { + return { + allowed: false, + retryAfterSeconds: Math.ceil((state.lockedUntil - now) / 1000), + }; + } + return { allowed: true }; +} + +export function recordLoginFailure( + rawIp: string | null | undefined, + options: { enabled: boolean } +): GuardDecision { + if (!options.enabled) return { allowed: true }; + const key = clientKey(rawIp); + const now = nowMs(); + const existing = attempts.get(key); + + if (!existing || now - existing.firstAttemptAt > WINDOW_MS) { + attempts.set(key, { count: 1, firstAttemptAt: now, lockedUntil: null }); + return { allowed: true }; + } + + const nextCount = existing.count + 1; + if (nextCount >= FAILURE_THRESHOLD) { + const lockedUntil = now + LOCKOUT_MS; + attempts.set(key, { + count: nextCount, + firstAttemptAt: existing.firstAttemptAt, + lockedUntil, + }); + return { allowed: false, retryAfterSeconds: Math.ceil(LOCKOUT_MS / 1000) }; + } + + attempts.set(key, { + count: nextCount, + firstAttemptAt: existing.firstAttemptAt, + lockedUntil: null, + }); + return { allowed: true }; +} + +export function clearLoginAttempts(rawIp: string | null | undefined): void { + attempts.delete(clientKey(rawIp)); +} + +export function resetLoginGuardForTests(): void { + attempts.clear(); +} + +export const LOGIN_GUARD_TUNABLES = Object.freeze({ + WINDOW_MS, + LOCKOUT_MS, + FAILURE_THRESHOLD, +}); diff --git a/src/server/authz/assertAuth.ts b/src/server/authz/assertAuth.ts new file mode 100644 index 0000000000..2ac8e7de35 --- /dev/null +++ b/src/server/authz/assertAuth.ts @@ -0,0 +1,88 @@ +import { + AUTHZ_HEADER_AUTH_ID, + AUTHZ_HEADER_AUTH_KIND, + AUTHZ_HEADER_AUTH_LABEL, + AUTHZ_HEADER_AUTH_SCOPES, + AUTHZ_HEADER_ROUTE_CLASS, +} from "./headers"; +import type { AuthSubject, RouteClass } from "./types"; + +export class AuthzAssertionError extends Error { + readonly status: number; + readonly code: string; + + constructor(code: string, message: string, status = 500) { + super(message); + this.name = "AuthzAssertionError"; + this.code = code; + this.status = status; + } +} + +type HeaderSource = Headers | { get(name: string): string | null }; + +function readHeader(source: HeaderSource, name: string): string | null { + return source.get(name) ?? null; +} + +function isHeaderSource(value: unknown): value is HeaderSource { + if (value instanceof Headers) return true; + if (typeof value !== "object" || value === null) return false; + return typeof (value as { get?: unknown }).get === "function"; +} + +export function readSubjectFromHeaders(headers: HeaderSource): AuthSubject { + const kind = (readHeader(headers, AUTHZ_HEADER_AUTH_KIND) ?? "anonymous") as AuthSubject["kind"]; + const id = readHeader(headers, AUTHZ_HEADER_AUTH_ID) ?? "anonymous"; + const label = readHeader(headers, AUTHZ_HEADER_AUTH_LABEL) ?? undefined; + const rawScopes = readHeader(headers, AUTHZ_HEADER_AUTH_SCOPES); + const scopes = rawScopes + ? rawScopes + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; + return { kind, id, label, scopes }; +} + +export function readRouteClassFromHeaders(headers: HeaderSource): RouteClass | null { + const raw = readHeader(headers, AUTHZ_HEADER_ROUTE_CLASS); + if (raw === "PUBLIC" || raw === "CLIENT_API" || raw === "MANAGEMENT") return raw; + return null; +} + +export function assertAuth( + request: Request | { headers: HeaderSource }, + expected: RouteClass +): AuthSubject { + const headers = request instanceof Request ? request.headers : request.headers; + + if (!isHeaderSource(headers)) { + throw new AuthzAssertionError("AUTHZ_INVALID_REQUEST", "Request headers are unavailable", 500); + } + + const actualClass = readRouteClassFromHeaders(headers); + if (!actualClass) { + throw new AuthzAssertionError( + "AUTHZ_NOT_INITIALIZED", + "Request did not pass through the authz middleware", + 500 + ); + } + + if (actualClass !== expected) { + throw new AuthzAssertionError( + "AUTHZ_ROUTE_CLASS_MISMATCH", + `Expected ${expected} but got ${actualClass}`, + 500 + ); + } + + const subject = readSubjectFromHeaders(headers); + + if (expected !== "PUBLIC" && subject.kind === "anonymous") { + throw new AuthzAssertionError("AUTHZ_UNAUTHENTICATED", "Authentication required", 401); + } + + return subject; +} diff --git a/src/server/authz/classify.ts b/src/server/authz/classify.ts new file mode 100644 index 0000000000..e0c00e546c --- /dev/null +++ b/src/server/authz/classify.ts @@ -0,0 +1,122 @@ +import { + PUBLIC_API_ROUTE_PREFIXES, + PUBLIC_READONLY_API_ROUTE_PREFIXES, + PUBLIC_READONLY_METHODS, +} from "../../shared/constants/publicApiRoutes"; +import type { ClassificationReason, RouteClass, RouteClassification } from "./types"; + +const CLIENT_API_ALIAS_PREFIXES: ReadonlyArray<{ alias: string; canonical: string }> = [ + { alias: "/chat/completions", canonical: "/api/v1/chat/completions" }, + { alias: "/responses", canonical: "/api/v1/responses" }, + { alias: "/models", canonical: "/api/v1/models" }, +]; + +function normalizePathname(rawPath: string): { path: string; reason?: ClassificationReason } { + let path = rawPath || "/"; + if (!path.startsWith("/")) path = "/" + path; + if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1); + + if (path === "/codex" || path.startsWith("/codex/")) { + return { path: "/api/v1/responses", reason: "client_api_codex_alias" }; + } + + if (path === "/v1/v1" || path.startsWith("/v1/v1/")) { + const tail = path.slice("/v1/v1".length) || ""; + return { path: "/api/v1" + tail, reason: "client_api_double_prefix" }; + } + + if (path === "/v1" || path.startsWith("/v1/")) { + const tail = path.slice("/v1".length) || ""; + return { path: "/api/v1" + tail, reason: "client_api_alias" }; + } + + for (const { alias, canonical } of CLIENT_API_ALIAS_PREFIXES) { + if (path === alias) { + return { path: canonical, reason: "client_api_alias" }; + } + if (path.startsWith(alias + "/")) { + return { path: canonical + path.slice(alias.length), reason: "client_api_alias" }; + } + } + + return { path }; +} + +export function classifyRoute(rawPath: string, method: string = "GET"): RouteClassification { + const { path: normalizedPath, reason: aliasReason } = normalizePathname(rawPath); + + if (normalizedPath === "/" || normalizedPath === "") { + return { + routeClass: "MANAGEMENT", + reason: "root_redirect", + normalizedPath: "/", + }; + } + + if (normalizedPath.startsWith("/dashboard")) { + return { + routeClass: "MANAGEMENT", + reason: "dashboard_prefix", + normalizedPath, + }; + } + + if (normalizedPath === "/api/v1" || normalizedPath.startsWith("/api/v1/")) { + return { + routeClass: "CLIENT_API", + reason: aliasReason ?? "client_api_v1", + normalizedPath, + }; + } + + if (normalizedPath.startsWith("/api/")) { + if (isClassifiedAsPublic(normalizedPath, method)) { + return { + routeClass: "PUBLIC", + reason: matchesReadonlyPublic(normalizedPath, method) + ? "public_readonly_prefix" + : "public_prefix", + normalizedPath, + }; + } + + return { + routeClass: "MANAGEMENT", + reason: "management_api", + normalizedPath, + }; + } + + return { + routeClass: "MANAGEMENT", + reason: "fallback_management", + normalizedPath, + }; +} + +function matchesReadonlyPublic(path: string, method: string): boolean { + if (!PUBLIC_READONLY_METHODS.has(String(method).toUpperCase())) return false; + return PUBLIC_READONLY_API_ROUTE_PREFIXES.some((p) => path.startsWith(p)); +} + +function isClassifiedAsPublic(path: string, method: string): boolean { + const isV1ApiPrefix = (p: string) => + p === "/api/v1" || p === "/api/v1/" || p.startsWith("/api/v1/"); + const filtered = PUBLIC_API_ROUTE_PREFIXES.filter((p) => p !== "/api/v1/"); + if (filtered.some((prefix) => path.startsWith(prefix)) && !isV1ApiPrefix(path)) { + return true; + } + return matchesReadonlyPublic(path, method); +} + +export function isClientApi(routeClass: RouteClass): boolean { + return routeClass === "CLIENT_API"; +} + +export function isManagement(routeClass: RouteClass): boolean { + return routeClass === "MANAGEMENT"; +} + +export function isPublic(routeClass: RouteClass): boolean { + return routeClass === "PUBLIC"; +} diff --git a/src/server/authz/context.ts b/src/server/authz/context.ts new file mode 100644 index 0000000000..01f13be038 --- /dev/null +++ b/src/server/authz/context.ts @@ -0,0 +1,42 @@ +import type { AuthSubject, RouteClass, RouteClassification } from "./types"; + +export interface AuthDecision { + allow: true; + subject: AuthSubject; +} + +export interface AuthRejection { + allow: false; + status: number; + code: string; + message: string; +} + +export type AuthOutcome = AuthDecision | AuthRejection; + +export interface RequestLike { + method: string; + headers: Headers; + cookies?: { get?: (name: string) => { value?: string } | undefined }; + nextUrl?: { pathname?: string | null } | null; + url?: string; +} + +export interface PolicyContext { + request: RequestLike; + classification: RouteClassification; + requestId: string; +} + +export interface RoutePolicy { + readonly routeClass: RouteClass; + evaluate(ctx: PolicyContext): Promise; +} + +export function allow(subject: AuthSubject): AuthDecision { + return { allow: true, subject }; +} + +export function reject(status: number, code: string, message: string): AuthRejection { + return { allow: false, status, code, message }; +} diff --git a/src/server/authz/headers.ts b/src/server/authz/headers.ts new file mode 100644 index 0000000000..906befc39a --- /dev/null +++ b/src/server/authz/headers.ts @@ -0,0 +1,34 @@ +/** + * Header constants used by the authz pipeline. + * + * Middleware adds these headers to the upstream request after a successful + * auth decision. Route handlers and downstream services read them through + * the assertAuth() helper instead of re-running auth logic. + * + * All header names are lowercase to match Next.js / fetch semantics. + * + * IMPORTANT: these headers are stripped from incoming client requests + * before classification (see pipeline.ts) so a remote caller cannot + * pre-populate them and impersonate a privileged subject. + */ + +export const AUTHZ_HEADER_REQUEST_ID = "x-request-id"; + +export const AUTHZ_HEADER_ROUTE_CLASS = "x-omniroute-route-class"; + +export const AUTHZ_HEADER_AUTH_KIND = "x-omniroute-auth-kind"; +export const AUTHZ_HEADER_AUTH_ID = "x-omniroute-auth-id"; +export const AUTHZ_HEADER_AUTH_LABEL = "x-omniroute-auth-label"; +export const AUTHZ_HEADER_AUTH_SCOPES = "x-omniroute-auth-scopes"; + +/** + * Headers the pipeline must NEVER trust on incoming requests. They are + * stripped before route classification to prevent header-spoofing attacks. + */ +export const AUTHZ_TRUSTED_HEADERS: ReadonlyArray = [ + AUTHZ_HEADER_ROUTE_CLASS, + AUTHZ_HEADER_AUTH_KIND, + AUTHZ_HEADER_AUTH_ID, + AUTHZ_HEADER_AUTH_LABEL, + AUTHZ_HEADER_AUTH_SCOPES, +]; diff --git a/src/server/authz/pipeline.ts b/src/server/authz/pipeline.ts new file mode 100644 index 0000000000..c4226a7bd7 --- /dev/null +++ b/src/server/authz/pipeline.ts @@ -0,0 +1,243 @@ +import { jwtVerify, SignJWT } from "jose"; +import { NextResponse, type NextRequest } from "next/server"; +import { isDraining } from "../../lib/gracefulShutdown"; +import { checkBodySize, getBodySizeLimit } from "../../shared/middleware/bodySizeGuard"; +import { generateRequestId } from "../../shared/utils/requestId"; +import { applyCorsHeaders } from "../cors/origins"; +import { classifyRoute } from "./classify"; +import { clientApiPolicy } from "./policies/clientApi"; +import { managementPolicy } from "./policies/management"; +import { publicPolicy } from "./policies/public"; +import { + AUTHZ_HEADER_AUTH_ID, + AUTHZ_HEADER_AUTH_KIND, + AUTHZ_HEADER_AUTH_LABEL, + AUTHZ_HEADER_AUTH_SCOPES, + AUTHZ_HEADER_REQUEST_ID, + AUTHZ_HEADER_ROUTE_CLASS, + AUTHZ_TRUSTED_HEADERS, +} from "./headers"; +import type { AuthSubject, RouteClass, RouteClassification } from "./types"; +import type { AuthOutcome, RoutePolicy } from "./context"; + +export interface AuthzPipelineOptions { + enforce?: boolean; +} + +const POLICIES: Record = { + PUBLIC: publicPolicy, + CLIENT_API: clientApiPolicy, + MANAGEMENT: managementPolicy, +}; + +function stampSubject(headers: Headers, subject: AuthSubject): void { + headers.set(AUTHZ_HEADER_AUTH_KIND, subject.kind); + headers.set(AUTHZ_HEADER_AUTH_ID, subject.id); + if (subject.label) headers.set(AUTHZ_HEADER_AUTH_LABEL, subject.label); + if (subject.scopes && subject.scopes.length > 0) { + headers.set(AUTHZ_HEADER_AUTH_SCOPES, subject.scopes.join(",")); + } +} + +function rejectionResponse( + outcome: Extract, + classification: RouteClassification, + requestId: string +): NextResponse { + const response = NextResponse.json( + { + error: { + code: outcome.code, + message: outcome.message, + correlation_id: requestId, + }, + }, + { status: outcome.status } + ); + response.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId); + response.headers.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass); + return response; +} + +function isDashboardPath(pathname: string): boolean { + return pathname === "/dashboard" || pathname.startsWith("/dashboard/"); +} + +function getCookieValue(request: NextRequest, name: string): string | null { + const fromCookies = request.cookies.get(name)?.value; + if (fromCookies) return fromCookies; + + const cookieHeader = request.headers.get("cookie") || request.headers.get("Cookie"); + if (!cookieHeader) return null; + + for (const segment of cookieHeader.split(";")) { + const [rawKey, ...rawValue] = segment.split("="); + if (!rawKey || rawValue.length === 0) continue; + if (rawKey.trim() === name) return rawValue.join("=").trim() || null; + } + + return null; +} + +function getJwtSecret(): Uint8Array | null { + const secret = process.env.JWT_SECRET?.trim(); + return secret ? new TextEncoder().encode(secret) : null; +} + +function shouldUseSecureCookie(request: NextRequest): boolean { + if (process.env.AUTH_COOKIE_SECURE === "true") return true; + const forwardedProto = (request.headers.get("x-forwarded-proto") || "") + .split(",")[0] + .trim() + .toLowerCase(); + return forwardedProto === "https" || request.nextUrl.protocol === "https:"; +} + +async function refreshDashboardSessionIfNeeded( + response: NextResponse, + request: NextRequest +): Promise { + const secret = getJwtSecret(); + if (!secret) return; + + const token = getCookieValue(request, "auth_token"); + if (!token) return; + + try { + const { payload } = await jwtVerify(token, secret); + const exp = typeof payload.exp === "number" ? payload.exp : null; + if (!exp) return; + + const now = Math.floor(Date.now() / 1000); + const refreshWindowSeconds = 7 * 24 * 60 * 60; + if (exp - now >= refreshWindowSeconds) return; + + const freshToken = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("30d") + .sign(secret); + + response.cookies.set("auth_token", freshToken, { + httpOnly: true, + secure: shouldUseSecureCookie(request), + sameSite: "lax", + path: "/", + }); + } catch (error) { + console.error("[Authz] JWT auto-refresh failed:", error); + } +} + +function dashboardLoginRedirect(request: NextRequest, requestId: string): NextResponse { + const response = NextResponse.redirect(new URL("/login", request.url)); + response.cookies.delete("auth_token"); + stampRouteResponse(response, requestId, "MANAGEMENT"); + applyCorsHeaders(response, request); + return response; +} + +function drainingResponse(requestId: string): NextResponse { + const response = NextResponse.json( + { + error: { + code: "SERVICE_UNAVAILABLE", + message: "Server is shutting down", + correlation_id: requestId, + }, + }, + { status: 503 } + ); + response.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId); + return response; +} + +function stampRouteResponse( + response: Response, + requestId: string, + routeClass: RouteClass +): Response { + response.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId); + response.headers.set(AUTHZ_HEADER_ROUTE_CLASS, routeClass); + return response; +} + +export async function runAuthzPipeline( + request: NextRequest, + options: AuthzPipelineOptions = {} +): Promise { + const { pathname } = request.nextUrl; + const method = request.method; + + const requestId = generateRequestId(); + + if (pathname === "/") { + const response = NextResponse.redirect(new URL("/dashboard", request.url)); + return stampRouteResponse(response, requestId, "MANAGEMENT"); + } + + const classification = classifyRoute(pathname, method); + + if (pathname.startsWith("/api/") && isDraining()) { + const response = drainingResponse(requestId); + stampRouteResponse(response, requestId, classification.routeClass); + applyCorsHeaders(response, request); + return response; + } + + if (pathname.startsWith("/api/") && method !== "GET" && method !== "OPTIONS") { + const bodySizeRejection = checkBodySize(request, getBodySizeLimit(pathname)); + if (bodySizeRejection) { + stampRouteResponse(bodySizeRejection, requestId, classification.routeClass); + applyCorsHeaders(bodySizeRejection, request); + return bodySizeRejection; + } + } + + const requestHeaders = new Headers(request.headers); + for (const trusted of AUTHZ_TRUSTED_HEADERS) { + requestHeaders.delete(trusted); + } + + requestHeaders.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass); + requestHeaders.set(AUTHZ_HEADER_REQUEST_ID, requestId); + + if (method === "OPTIONS") { + const preflight = new NextResponse(null, { status: 204 }); + preflight.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId); + preflight.headers.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass); + applyCorsHeaders(preflight, request); + return preflight; + } + + if (!options.enforce) { + const response = NextResponse.next({ request: { headers: requestHeaders } }); + response.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId); + response.headers.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass); + applyCorsHeaders(response, request); + return response; + } + + const policy = POLICIES[classification.routeClass]; + const outcome = await policy.evaluate({ request, classification, requestId }); + + if (!outcome.allow) { + if (classification.routeClass === "MANAGEMENT" && isDashboardPath(pathname)) { + return dashboardLoginRedirect(request, requestId); + } + + const rejection = rejectionResponse(outcome, classification, requestId); + applyCorsHeaders(rejection, request); + return rejection; + } + + stampSubject(requestHeaders, outcome.subject); + + const response = NextResponse.next({ request: { headers: requestHeaders } }); + response.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId); + response.headers.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass); + applyCorsHeaders(response, request); + if (classification.routeClass === "MANAGEMENT" && isDashboardPath(pathname)) { + await refreshDashboardSessionIfNeeded(response, request); + } + return response; +} diff --git a/src/server/authz/policies/clientApi.ts b/src/server/authz/policies/clientApi.ts new file mode 100644 index 0000000000..bfd2c902e1 --- /dev/null +++ b/src/server/authz/policies/clientApi.ts @@ -0,0 +1,33 @@ +import type { AuthOutcome, PolicyContext, RoutePolicy } from "../context"; +import { allow, reject } from "../context"; + +function extractBearer(headers: Headers): string | null { + const raw = headers.get("authorization") ?? headers.get("Authorization"); + if (!raw) return null; + const trimmed = raw.trim(); + if (!trimmed.toLowerCase().startsWith("bearer ")) return null; + return trimmed.slice(7).trim() || null; +} + +function maskKeyId(apiKey: string): string { + const tail = apiKey.slice(-4); + return `key_${tail}`; +} + +export const clientApiPolicy: RoutePolicy = { + routeClass: "CLIENT_API", + async evaluate(ctx: PolicyContext): Promise { + const bearer = extractBearer(ctx.request.headers); + if (!bearer) { + return reject(401, "AUTH_002", "Authentication required"); + } + + const { validateApiKey } = await import("../../../lib/db/apiKeys"); + const ok = await validateApiKey(bearer); + if (!ok) { + return reject(401, "AUTH_002", "Invalid API key"); + } + + return allow({ kind: "client_api_key", id: maskKeyId(bearer) }); + }, +}; diff --git a/src/server/authz/policies/management.ts b/src/server/authz/policies/management.ts new file mode 100644 index 0000000000..8caae11d6f --- /dev/null +++ b/src/server/authz/policies/management.ts @@ -0,0 +1,40 @@ +import { isModelSyncInternalRequest } from "../../../shared/services/modelSyncScheduler"; +import { isAuthRequired, isDashboardSessionAuthenticated } from "../../../shared/utils/apiAuth"; +import type { AuthOutcome, PolicyContext, RoutePolicy } from "../context"; +import { allow, reject } from "../context"; + +const MODEL_SYNC_MANAGEMENT_PATH = /^\/api\/providers\/[^/]+\/(sync-models|models)$/; + +function hasBearerToken(headers: Headers): boolean { + const authHeader = headers.get("authorization") ?? headers.get("Authorization"); + return typeof authHeader === "string" && authHeader.trim().toLowerCase().startsWith("bearer "); +} + +function isInternalModelSyncRequest(ctx: PolicyContext): boolean { + if (!MODEL_SYNC_MANAGEMENT_PATH.test(ctx.classification.normalizedPath)) return false; + return isModelSyncInternalRequest(ctx.request); +} + +export const managementPolicy: RoutePolicy = { + routeClass: "MANAGEMENT", + async evaluate(ctx: PolicyContext): Promise { + if (!(await isAuthRequired())) { + return allow({ kind: "anonymous", id: "anonymous", label: "auth-disabled" }); + } + + if (isInternalModelSyncRequest(ctx)) { + return allow({ kind: "management_key", id: "model-sync", label: "internal-model-sync" }); + } + + if (await isDashboardSessionAuthenticated(ctx.request)) { + return allow({ kind: "dashboard_session", id: "dashboard" }); + } + + const bearerPresent = hasBearerToken(ctx.request.headers); + return reject( + bearerPresent ? 403 : 401, + "AUTH_001", + bearerPresent ? "Invalid management token" : "Authentication required" + ); + }, +}; diff --git a/src/server/authz/policies/public.ts b/src/server/authz/policies/public.ts new file mode 100644 index 0000000000..588eebc9d6 --- /dev/null +++ b/src/server/authz/policies/public.ts @@ -0,0 +1,9 @@ +import type { AuthOutcome, PolicyContext, RoutePolicy } from "../context"; +import { allow } from "../context"; + +export const publicPolicy: RoutePolicy = { + routeClass: "PUBLIC", + async evaluate(_ctx: PolicyContext): Promise { + return allow({ kind: "anonymous", id: "anonymous" }); + }, +}; diff --git a/src/server/authz/types.ts b/src/server/authz/types.ts new file mode 100644 index 0000000000..a8fd786408 --- /dev/null +++ b/src/server/authz/types.ts @@ -0,0 +1,74 @@ +/** + * Unified authz type definitions. + * + * These types are the canonical contract between the Next.js middleware, + * the policy layer, and any defense-in-depth helpers used by route handlers. + * + * Three classes of HTTP routes are recognized: + * + * - PUBLIC — explicitly safe routes (login, logout, status, health, + * onboarding bootstrap). + * - CLIENT_API — model-serving and OpenAI/Anthropic-compatible endpoints + * protected by API keys. + * - MANAGEMENT — dashboard pages, settings, providers, keys, admin and + * diagnostics endpoints protected by dashboard session + * or management-grade credentials. + * + * Any route that cannot be classified MUST fail closed. + */ + +export type RouteClass = "PUBLIC" | "CLIENT_API" | "MANAGEMENT"; + +/** + * Why a particular path was placed into a route class. Used for telemetry, + * diagnostics, and tests so we can prove that routing decisions are + * deterministic and reviewable. + */ +export type ClassificationReason = + | "public_prefix" + | "public_readonly_prefix" + | "dashboard_prefix" + | "client_api_v1" + | "client_api_alias" + | "client_api_codex_alias" + | "client_api_double_prefix" + | "management_api" + | "root_redirect" + | "fallback_management"; + +export interface RouteClassification { + routeClass: RouteClass; + reason: ClassificationReason; + /** + * The normalized internal pathname after rewrites. For example + * "/v1/chat/completions" or "/codex/foo" is normalized to + * "/api/v1/chat/completions" / "/api/v1/responses" so policy code does + * not have to know about every alias. + */ + normalizedPath: string; +} + +/** + * Identity of the authenticated principal once a policy has accepted the + * request. Populated by the policy layer (Phase 2) and consumed by route + * handlers via assertAuth(). + */ +export interface AuthSubject { + kind: "client_api_key" | "dashboard_session" | "management_key" | "anonymous"; + /** + * Stable identifier of the principal: + * - hashed key id for API keys + * - "dashboard" for the single-tenant dashboard session + * - "anonymous" for unauthenticated PUBLIC requests + */ + id: string; + /** + * Optional human-friendly label (key name, scope hint). Never includes + * the raw secret. + */ + label?: string; + /** + * Scopes granted to this subject. Empty for unauthenticated subjects. + */ + scopes?: ReadonlyArray; +} diff --git a/src/server/cors/origins.ts b/src/server/cors/origins.ts new file mode 100644 index 0000000000..39fa792cef --- /dev/null +++ b/src/server/cors/origins.ts @@ -0,0 +1,124 @@ +/** + * Centralized CORS origin allowlist. + * + * Source of truth for which browser origins may call OmniRoute over CORS. + * No wildcard default. To allow any origin, opt in via `CORS_ALLOW_ALL=true`. + * + * Resolution order: + * 1. If `CORS_ALLOW_ALL=true` → echo any origin back (effectively `*`, + * but with `Vary: Origin` so caches stay correct). + * 2. Otherwise, the request's `Origin` is matched (case-insensitive, + * trailing slash ignored) against the allowlist. + * 3. Allowlist sources (merged): env `CORS_ALLOWED_ORIGINS` (csv) and + * anything injected at runtime via `setRuntimeAllowedOrigins()` from + * the persisted settings layer. + * + * The middleware applies the resolved value via `applyCorsHeaders()`. + * Per-route handlers no longer set `Access-Control-Allow-Origin` themselves. + */ +const ENV_ALLOW_ALL = "CORS_ALLOW_ALL"; +const ENV_ALLOWED = "CORS_ALLOWED_ORIGINS"; +const LEGACY_ENV_SINGLE = "CORS_ORIGIN"; + +const STANDARD_ALLOW_HEADERS = + "Content-Type, Authorization, x-api-key, anthropic-version, x-omniroute-connection, x-internal-test, accept"; +const STANDARD_ALLOW_METHODS = "GET, POST, PUT, DELETE, PATCH, OPTIONS"; + +let runtimeAllowedOrigins: ReadonlySet = new Set(); + +/** + * Persisted-setting hook. The settings layer should call this whenever the + * stored `corsOrigins` value changes (csv string). + */ +export function setRuntimeAllowedOrigins(csv: string | null | undefined): void { + runtimeAllowedOrigins = parseOriginList(csv); +} + +export function getRuntimeAllowedOrigins(): ReadonlySet { + return runtimeAllowedOrigins; +} + +function parseOriginList(value: string | null | undefined): Set { + const result = new Set(); + if (!value) return result; + for (const raw of value.split(",")) { + const v = raw.trim(); + if (!v) continue; + result.add(normalizeOrigin(v)); + } + return result; +} + +function normalizeOrigin(origin: string): string { + return origin.toLowerCase().replace(/\/+$/, ""); +} + +function envAllowAll(): boolean { + const raw = process.env[ENV_ALLOW_ALL]; + if (!raw) { + // Backward-compat: legacy `CORS_ORIGIN=*` behaves like allow-all. + return process.env[LEGACY_ENV_SINGLE]?.trim() === "*"; + } + return raw.trim().toLowerCase() === "true" || raw.trim() === "1"; +} + +function envAllowedOrigins(): Set { + const fromList = parseOriginList(process.env[ENV_ALLOWED]); + const legacy = process.env[LEGACY_ENV_SINGLE]?.trim(); + if (legacy && legacy !== "*") { + fromList.add(normalizeOrigin(legacy)); + } + return fromList; +} + +/** + * Resolve which value should be returned in `Access-Control-Allow-Origin` + * for the given request origin, or `null` if the origin is not allowed. + * + * Returns the original (un-normalized) origin string when allowed so the + * browser sees an exact echo and cookies/credentials work correctly. + */ +export function resolveAllowedOrigin(requestOrigin: string | null | undefined): string | null { + if (envAllowAll()) { + // `*` is only safe when no credentials are involved; if a request did + // arrive with an Origin header, echo it back with Vary so credentialed + // flows can opt in via the explicit allowlist instead. + return requestOrigin && requestOrigin.length > 0 ? requestOrigin : "*"; + } + if (!requestOrigin) return null; + const normalized = normalizeOrigin(requestOrigin); + if (envAllowedOrigins().has(normalized)) return requestOrigin; + if (runtimeAllowedOrigins.has(normalized)) return requestOrigin; + return null; +} + +/** + * Apply CORS headers to a response in-place. Safe to call on any response + * (rejections, preflight, normal `next()` continuations). When the origin + * is not allowed, no `Access-Control-Allow-Origin` is added — browsers + * will block the response, which is the desired fail-closed behavior. + */ +export function applyCorsHeaders(response: Response, request: Request): void { + const requestOrigin = request.headers.get("origin"); + const allowed = resolveAllowedOrigin(requestOrigin); + if (allowed !== null) { + response.headers.set("Access-Control-Allow-Origin", allowed); + response.headers.append("Vary", "Origin"); + } + response.headers.set("Access-Control-Allow-Methods", STANDARD_ALLOW_METHODS); + response.headers.set("Access-Control-Allow-Headers", STANDARD_ALLOW_HEADERS); + const requestedHeaders = request.headers.get("access-control-request-headers"); + if (requestedHeaders) { + response.headers.set("Access-Control-Allow-Headers", requestedHeaders); + } +} + +/** + * Plain-object form for handlers that still need static CORS headers + * (no origin echo). Middleware overlays the proper `Access-Control-Allow-Origin` + * later on the way out. + */ +export const STATIC_CORS_HEADERS: Readonly> = Object.freeze({ + "Access-Control-Allow-Methods": STANDARD_ALLOW_METHODS, + "Access-Control-Allow-Headers": STANDARD_ALLOW_HEADERS, +}); diff --git a/src/shared/middleware/bodySizeGuard.ts b/src/shared/middleware/bodySizeGuard.ts index f028484f0c..80c3a4946f 100644 --- a/src/shared/middleware/bodySizeGuard.ts +++ b/src/shared/middleware/bodySizeGuard.ts @@ -65,7 +65,6 @@ export function checkBodySize(request: Request, limit: number = MAX_BODY_BYTES): status: 413, headers: { "Content-Type": "application/json", - "Access-Control-Allow-Origin": process.env.CORS_ORIGIN || "*", }, } ); diff --git a/src/shared/network/remoteImageFetch.ts b/src/shared/network/remoteImageFetch.ts new file mode 100644 index 0000000000..15d553ce8b --- /dev/null +++ b/src/shared/network/remoteImageFetch.ts @@ -0,0 +1,118 @@ +import { + type OutboundUrlGuardMode, + getProviderOutboundGuard, + parseAndValidatePublicUrl, + parseOutboundUrl, +} from "@/shared/network/outboundUrlGuard"; + +const DEFAULT_MAX_REMOTE_IMAGE_BYTES = 20 * 1024 * 1024; +const DEFAULT_MAX_REDIRECTS = 3; +const DEFAULT_TIMEOUT_MS = 15000; + +export interface RemoteImageFetchOptions { + fetchImpl?: typeof fetch; + guard?: OutboundUrlGuardMode; + maxBytes?: number; + maxRedirects?: number; + signal?: AbortSignal; + timeoutMs?: number; +} + +export interface RemoteImageFetchResult { + buffer: Buffer; + contentType: string; + url: string; +} + +function validateRemoteImageUrl(input: string | URL, guard: OutboundUrlGuardMode) { + return guard === "public-only" ? parseAndValidatePublicUrl(input) : parseOutboundUrl(input); +} + +function combineSignals(signal: AbortSignal | undefined, timeoutMs: number) { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + if (!signal) return timeoutSignal; + return AbortSignal.any([signal, timeoutSignal]); +} + +async function readResponseBuffer(response: Response, maxBytes: number) { + const contentLengthHeader = response.headers.get("content-length"); + const contentLength = contentLengthHeader ? Number.parseInt(contentLengthHeader, 10) : null; + if (contentLength !== null && Number.isFinite(contentLength) && contentLength > maxBytes) { + throw new Error(`Remote image exceeds ${maxBytes} byte limit`); + } + + if (!response.body) { + const buffer = Buffer.from(await response.arrayBuffer()); + if (buffer.byteLength > maxBytes) { + throw new Error(`Remote image exceeds ${maxBytes} byte limit`); + } + return buffer; + } + + const reader = response.body.getReader(); + const chunks: Buffer[] = []; + let totalBytes = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = Buffer.from(value); + totalBytes += chunk.byteLength; + if (totalBytes > maxBytes) { + await reader.cancel(); + throw new Error(`Remote image exceeds ${maxBytes} byte limit`); + } + chunks.push(chunk); + } + } finally { + reader.releaseLock(); + } + + return Buffer.concat(chunks, totalBytes); +} + +export async function fetchRemoteImage( + input: string | URL, + options: RemoteImageFetchOptions = {} +): Promise { + const fetchImpl = options.fetchImpl ?? fetch; + const guard = options.guard ?? getProviderOutboundGuard(); + const maxBytes = options.maxBytes ?? DEFAULT_MAX_REMOTE_IMAGE_BYTES; + const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS; + const signal = combineSignals(options.signal, options.timeoutMs ?? DEFAULT_TIMEOUT_MS); + + let currentUrl = validateRemoteImageUrl(input, guard); + for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount++) { + const response = await fetchImpl(currentUrl.toString(), { + method: "GET", + redirect: "manual", + signal, + }); + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location"); + if (!location) { + throw new Error(`Remote image redirect missing Location header (${response.status})`); + } + if (redirectCount >= maxRedirects) { + throw new Error(`Remote image exceeded ${maxRedirects} redirect limit`); + } + currentUrl = validateRemoteImageUrl(new URL(location, currentUrl), guard); + continue; + } + + if (!response.ok) { + throw new Error(`Remote image fetch error ${response.status}`); + } + + return { + buffer: await readResponseBuffer(response, maxBytes), + contentType: response.headers.get("content-type") || "application/octet-stream", + url: currentUrl.toString(), + }; + } + + throw new Error(`Remote image exceeded ${maxRedirects} redirect limit`); +} diff --git a/src/shared/services/modelSyncScheduler.ts b/src/shared/services/modelSyncScheduler.ts index 67f7d3b147..f6d8d8e948 100644 --- a/src/shared/services/modelSyncScheduler.ts +++ b/src/shared/services/modelSyncScheduler.ts @@ -48,7 +48,7 @@ export function buildModelSyncInternalHeaders(): Record { return { [MODEL_SYNC_INTERNAL_AUTH_HEADER]: getInternalAuthToken() }; } -export function isModelSyncInternalRequest(request: Request): boolean { +export function isModelSyncInternalRequest(request: { headers: Headers }): boolean { if (!internalAuthToken && globalState.__omnirouteModelSyncInternalAuthToken) { internalAuthToken = globalState.__omnirouteModelSyncInternalAuthToken; } diff --git a/src/shared/utils/cors.ts b/src/shared/utils/cors.ts index 848e4ae8d5..75ef54d5ac 100644 --- a/src/shared/utils/cors.ts +++ b/src/shared/utils/cors.ts @@ -1,37 +1,24 @@ /** - * Shared CORS configuration for all API routes. + * Static CORS headers for route handlers. * - * Centralizes the Access-Control-Allow-Origin header so it can be - * configured via the CORS_ORIGIN environment variable instead of - * being hardcoded as "*" in every route handler. - * - * Usage: - * import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; - * - * // In route responses: - * return new Response(body, { headers: { ...CORS_HEADERS, "Content-Type": "application/json" } }); - * - * // For OPTIONS preflight: - * export function OPTIONS() { return handleCorsOptions(); } - */ - -export const CORS_ORIGIN = process.env.CORS_ORIGIN || "*"; - -/** - * Standard CORS headers to spread into any Response. - * @type {Record} + * `Access-Control-Allow-Origin` is intentionally NOT set here. The middleware + * (`src/middleware.ts` → `applyCorsHeaders`) is the single source of truth for + * which origin to echo, based on the central allowlist in + * `src/server/cors/origins.ts`. Route handlers may keep spreading + * `CORS_HEADERS` for the standard methods/allowed-headers; the middleware + * overlays the proper origin on the way out. */ export const CORS_HEADERS = { - "Access-Control-Allow-Origin": CORS_ORIGIN, - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization, x-api-key, anthropic-version, x-omniroute-connection, x-internal-test, accept", -}; +} as const; /** - * Handle CORS preflight (OPTIONS) request. - * @returns {Response} 204 No Content with CORS headers + * Preflight responder kept for routes that still ship their own OPTIONS handler. + * Returning 204 with `CORS_HEADERS` is enough; the middleware will add the + * allowed origin and `Vary: Origin` before the response leaves the server. */ -export function handleCorsOptions() { +export function handleCorsOptions(): Response { return new Response(null, { status: 204, headers: CORS_HEADERS }); } diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 909be6f7b5..8b768e9f78 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -434,9 +434,9 @@ export const updateSettingsSchema = z.object({ cloudUrl: z.string().max(500).optional(), baseUrl: z.string().max(500).optional(), setupComplete: z.boolean().optional(), - requireAuthForModels: z.boolean().optional(), blockedProviders: z.array(z.string().max(100)).optional(), hideHealthCheckLogs: z.boolean().optional(), + bruteForceProtection: z.boolean().optional(), hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(), comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(), // Routing settings (#134) diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 480c08392d..030bf8c511 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -43,7 +43,6 @@ export const updateSettingsSchema = z.object({ cloudUrl: z.string().max(500).optional(), baseUrl: z.string().max(500).optional(), setupComplete: z.boolean().optional(), - requireAuthForModels: z.boolean().optional(), blockedProviders: z.array(z.string().max(100)).optional(), hideHealthCheckLogs: z.boolean().optional(), debugMode: z.boolean().optional(), @@ -108,4 +107,5 @@ export const updateSettingsSchema = z.object({ // Missing settings lkgpEnabled: z.boolean().optional(), backgroundDegradation: z.unknown().optional(), + bruteForceProtection: z.boolean().optional(), }); diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index d9bf0763e7..b0a4139b08 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -3,7 +3,6 @@ import { getProviderCredentialsWithQuotaPreflight, markAccountUnavailable, extractApiKey, - isValidApiKey, } from "../services/auth"; import { getRuntimeProviderProfile, @@ -180,26 +179,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) { log.debug("AUTH", "No API key provided (local mode)"); } - // Optional strict API key mode for /v1 endpoints (require key on every request). const isComboLiveTest = request.headers?.get?.("x-internal-test") === "combo-health-check"; - if (process.env.REQUIRE_API_KEY === "true" && !isComboLiveTest) { - if (!apiKey) { - log.warn("AUTH", "Missing API key while REQUIRE_API_KEY=true"); - return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); - } - const valid = await isValidApiKey(apiKey); - if (!valid) { - log.warn("AUTH", "Invalid API key while REQUIRE_API_KEY=true"); - return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); - } - } else if (apiKey && !isComboLiveTest) { - // Client sent a Bearer key — it must exist in DB (otherwise reject to avoid "key ignored" confusion). - const valid = await isValidApiKey(apiKey); - if (!valid) { - log.warn("AUTH", "API key not found or invalid (must be created in API Manager)"); - return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); - } - } if (!modelStr) { log.warn("CHAT", "Missing model"); diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 0e36d867c5..ab480be2aa 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -28,7 +28,6 @@ declare namespace NodeJS { OMNIROUTE_DISABLE_BACKGROUND_SERVICES?: string; OMNIROUTE_PORT?: string; PRICING_SYNC_ENABLED?: string; - REQUIRE_API_KEY?: string; NODE_ENV?: "development" | "production" | "test"; } } diff --git a/tests/integration/proxy-pipeline.test.ts b/tests/integration/proxy-pipeline.test.ts index 42540df355..6895d258a1 100644 --- a/tests/integration/proxy-pipeline.test.ts +++ b/tests/integration/proxy-pipeline.test.ts @@ -402,9 +402,9 @@ describe("CORS — centralized configuration", () => { assert.ok(existsSync(full), "shared/utils/cors.ts should exist"); }); - it("should export CORS_HEADERS and CORS_ORIGIN", () => { + it("should export CORS_HEADERS without a wildcard origin", () => { const src = readSrc("shared/utils/cors.ts"); assert.match(src, /CORS_HEADERS/); - assert.match(src, /CORS_ORIGIN/); + assert.doesNotMatch(src, /Access-Control-Allow-Origin/); }); }); diff --git a/tests/unit/admin-audit-events.test.ts b/tests/unit/admin-audit-events.test.ts index 79520eb5be..4e902697e8 100644 --- a/tests/unit/admin-audit-events.test.ts +++ b/tests/unit/admin-audit-events.test.ts @@ -118,7 +118,7 @@ test("auth login route records failed password attempts", async () => { assert.equal(event.actor, "anonymous"); assert.equal(event.status, "failed"); assert.equal(event.requestId, "req-auth-failed"); - assert.deepEqual(event.metadata, { reason: "invalid_password" }); + assert.deepEqual(event.metadata, { reason: "invalid_password", lockedOut: false }); }); test("provider create/update/delete routes emit sanitized credential audit events", async () => { diff --git a/tests/unit/api-key-lifecycle.test.ts b/tests/unit/api-key-lifecycle.test.ts new file mode 100644 index 0000000000..5f7f6b6ed2 --- /dev/null +++ b/tests/unit/api-key-lifecycle.test.ts @@ -0,0 +1,119 @@ +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(), "omr-apikey-lifecycle-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); + +const ORIGINAL_OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY; +const ORIGINAL_ROUTER_API_KEY = process.env.ROUTER_API_KEY; + +function reset() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + delete process.env.OMNIROUTE_API_KEY; + delete process.env.ROUTER_API_KEY; +} + +test.beforeEach(() => { + reset(); +}); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_OMNIROUTE_API_KEY === undefined) delete process.env.OMNIROUTE_API_KEY; + else process.env.OMNIROUTE_API_KEY = ORIGINAL_OMNIROUTE_API_KEY; + if (ORIGINAL_ROUTER_API_KEY === undefined) delete process.env.ROUTER_API_KEY; + else process.env.ROUTER_API_KEY = ORIGINAL_ROUTER_API_KEY; +}); + +async function makeKey(name = "lifecycle-test", machineId = "machine-lifecycle") { + const created = await apiKeysDb.createApiKey(name, machineId); + assert.ok(created?.key, "createApiKey returned a key"); + return created; +} + +test("validateApiKey returns true for a fresh active key", async () => { + const created = await makeKey(); + assert.equal(await apiKeysDb.validateApiKey(created.key), true); +}); + +test("validateApiKey rejects revoked keys after revokeApiKey", async () => { + const created = await makeKey(); + assert.equal(await apiKeysDb.validateApiKey(created.key), true); + + const ok = await apiKeysDb.revokeApiKey(created.id); + assert.equal(ok, true); + + assert.equal(await apiKeysDb.validateApiKey(created.key), false); +}); + +test("validateApiKey rejects keys whose expires_at has passed", async () => { + const created = await makeKey(); + const past = new Date(Date.now() - 60_000).toISOString(); + const ok = await apiKeysDb.setApiKeyExpiry(created.id, past); + assert.equal(ok, true); + assert.equal(await apiKeysDb.validateApiKey(created.key), false); +}); + +test("validateApiKey accepts keys with future expires_at", async () => { + const created = await makeKey(); + const future = new Date(Date.now() + 60 * 60_000).toISOString(); + const ok = await apiKeysDb.setApiKeyExpiry(created.id, future); + assert.equal(ok, true); + assert.equal(await apiKeysDb.validateApiKey(created.key), true); +}); + +test("validateApiKey rejects deactivated keys (is_active=false)", async () => { + const created = await makeKey(); + const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { isActive: false }); + assert.equal(ok, true); + assert.equal(await apiKeysDb.validateApiKey(created.key), false); +}); + +test("revokeApiKey is idempotent and returns false for missing id", async () => { + const created = await makeKey(); + assert.equal(await apiKeysDb.revokeApiKey(created.id), true); + assert.equal(await apiKeysDb.revokeApiKey(created.id), true); + assert.equal(await apiKeysDb.revokeApiKey("00000000-0000-0000-0000-000000000000"), false); +}); + +test("getApiKeyMetadata exposes lifecycle and policy fields", async () => { + const created = await makeKey(); + await apiKeysDb.setApiKeyExpiry(created.id, new Date(Date.now() + 86_400_000).toISOString()); + + const md = await apiKeysDb.getApiKeyMetadata(created.key); + assert.ok(md); + assert.equal(md!.isActive, true); + assert.equal(md!.revokedAt, null); + assert.ok(md!.expiresAt && Date.parse(md!.expiresAt) > Date.now()); + assert.deepEqual(md!.ipAllowlist, []); + assert.deepEqual(md!.scopes, []); +}); + +test("validateApiKey accepts configured environment API keys", async () => { + process.env.OMNIROUTE_API_KEY = "sk-env-lifecycle-test"; + assert.equal(await apiKeysDb.validateApiKey("sk-env-lifecycle-test"), true); +}); + +test("validateApiKey updates last_used_at for persisted keys", async () => { + const created = await makeKey(); + + assert.equal(await apiKeysDb.validateApiKey(created.key), true); + + const db = core.getDbInstance() as { + prepare(sql: string): { get(id: string): { last_used_at: string | null } | undefined }; + }; + const row = db.prepare("SELECT last_used_at FROM api_keys WHERE id = ?").get(created.id); + + assert.ok(row?.last_used_at, "last_used_at should be set on successful validation"); + assert.ok(Date.parse(row.last_used_at) > 0, "last_used_at should be an ISO timestamp"); +}); diff --git a/tests/unit/audio-speech-handler.test.ts b/tests/unit/audio-speech-handler.test.ts index 181209dcf6..bdfe41b7bb 100644 --- a/tests/unit/audio-speech-handler.test.ts +++ b/tests/unit/audio-speech-handler.test.ts @@ -62,7 +62,7 @@ test("handleAudioSpeech proxies OpenAI-compatible providers with defaults", asyn }); assert.equal(response.status, 200); assert.equal(response.headers.get("content-type"), "audio/opus"); - assert.ok(response.headers.get("access-control-allow-origin")); + assert.match(response.headers.get("access-control-allow-methods") || "", /OPTIONS/); } finally { globalThis.fetch = originalFetch; } diff --git a/tests/unit/auth-clear-provider-routes.test.ts b/tests/unit/auth-clear-provider-routes.test.ts index 5dfc6b7d2a..a81b13c17d 100644 --- a/tests/unit/auth-clear-provider-routes.test.ts +++ b/tests/unit/auth-clear-provider-routes.test.ts @@ -95,40 +95,13 @@ test("moderations route clears stale provider error metadata on success", async } }); -test("moderations route covers CORS, API key auth, validation, and missing credential branches", async () => { +test("moderations route covers CORS, validation, and missing credential branches", async () => { await resetStorage(); const optionsResponse = await moderationRoute.OPTIONS(); assert.equal(optionsResponse.status, 200); assert.equal(optionsResponse.headers.get("Access-Control-Allow-Methods"), "POST, OPTIONS"); - await withEnv("REQUIRE_API_KEY", "true", async () => { - const missingKeyResponse = await moderationRoute.POST( - new Request("http://localhost/v1/moderations", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ input: "hello" }), - }) - ); - - assert.equal(missingKeyResponse.status, 401); - assert.match(await missingKeyResponse.text(), /Missing API key/i); - - const invalidKeyResponse = await moderationRoute.POST( - new Request("http://localhost/v1/moderations", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer invalid-test-key", - }, - body: JSON.stringify({ input: "hello" }), - }) - ); - - assert.equal(invalidKeyResponse.status, 401); - assert.match(await invalidKeyResponse.text(), /Invalid API key/i); - }); - const invalidJsonResponse = await moderationRoute.POST( new Request("http://localhost/v1/moderations", { method: "POST", diff --git a/tests/unit/auth/loginGuard.test.ts b/tests/unit/auth/loginGuard.test.ts new file mode 100644 index 0000000000..1dfb82ece1 --- /dev/null +++ b/tests/unit/auth/loginGuard.test.ts @@ -0,0 +1,65 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { + checkLoginGuard, + clearLoginAttempts, + recordLoginFailure, + resetLoginGuardForTests, + LOGIN_GUARD_TUNABLES, +} from "../../../src/server/auth/loginGuard"; + +describe("loginGuard", () => { + beforeEach(() => { + resetLoginGuardForTests(); + }); + + it("is a no-op when bruteForceProtection is disabled", () => { + for (let i = 0; i < 20; i++) { + const decision = recordLoginFailure("1.2.3.4", { enabled: false }); + assert.equal(decision.allowed, true); + } + assert.equal(checkLoginGuard("1.2.3.4", { enabled: false }).allowed, true); + }); + + it("allows the first attempts up to threshold-1, locks on the threshold hit", () => { + const ip = "10.0.0.1"; + for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD - 1; i++) { + const dec = recordLoginFailure(ip, { enabled: true }); + assert.equal(dec.allowed, true, `attempt #${i + 1} should still be allowed`); + } + const lockingHit = recordLoginFailure(ip, { enabled: true }); + assert.equal(lockingHit.allowed, false); + assert.ok((lockingHit.retryAfterSeconds || 0) > 0); + + const subsequent = checkLoginGuard(ip, { enabled: true }); + assert.equal(subsequent.allowed, false); + assert.ok((subsequent.retryAfterSeconds || 0) > 0); + }); + + it("scopes lockouts per IP", () => { + const ipA = "10.0.0.1"; + const ipB = "10.0.0.2"; + for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) { + recordLoginFailure(ipA, { enabled: true }); + } + assert.equal(checkLoginGuard(ipA, { enabled: true }).allowed, false); + assert.equal(checkLoginGuard(ipB, { enabled: true }).allowed, true); + }); + + it("clearLoginAttempts releases the lock for that IP only", () => { + const ip = "10.0.0.7"; + for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) { + recordLoginFailure(ip, { enabled: true }); + } + assert.equal(checkLoginGuard(ip, { enabled: true }).allowed, false); + clearLoginAttempts(ip); + assert.equal(checkLoginGuard(ip, { enabled: true }).allowed, true); + }); + + it("treats null/undefined ip as a single bucket", () => { + for (let i = 0; i < LOGIN_GUARD_TUNABLES.FAILURE_THRESHOLD; i++) { + recordLoginFailure(null, { enabled: true }); + } + assert.equal(checkLoginGuard(undefined, { enabled: true }).allowed, false); + }); +}); diff --git a/tests/unit/authz/classify.test.ts b/tests/unit/authz/classify.test.ts new file mode 100644 index 0000000000..3811697050 --- /dev/null +++ b/tests/unit/authz/classify.test.ts @@ -0,0 +1,190 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { classifyRoute } from "../../../src/server/authz/classify.ts"; +import type { RouteClass } from "../../../src/server/authz/types.ts"; + +interface Case { + name: string; + path: string; + method?: string; + expectedClass: RouteClass; + expectedNormalized?: string; +} + +const cases: Case[] = [ + { name: "root /", path: "/", expectedClass: "MANAGEMENT", expectedNormalized: "/" }, + { name: "dashboard root", path: "/dashboard", expectedClass: "MANAGEMENT" }, + { name: "dashboard nested", path: "/dashboard/settings", expectedClass: "MANAGEMENT" }, + { name: "dashboard onboarding", path: "/dashboard/onboarding", expectedClass: "MANAGEMENT" }, + + { + name: "/api/v1 base", + path: "/api/v1", + expectedClass: "CLIENT_API", + expectedNormalized: "/api/v1", + }, + { + name: "/api/v1/chat/completions", + path: "/api/v1/chat/completions", + expectedClass: "CLIENT_API", + }, + { name: "/api/v1/responses", path: "/api/v1/responses", expectedClass: "CLIENT_API" }, + { name: "/api/v1/models", path: "/api/v1/models", expectedClass: "CLIENT_API" }, + { name: "/api/v1/embeddings", path: "/api/v1/embeddings", expectedClass: "CLIENT_API" }, + { name: "/api/v1/files", path: "/api/v1/files", expectedClass: "CLIENT_API" }, + { name: "/api/v1/batches", path: "/api/v1/batches", expectedClass: "CLIENT_API" }, + { name: "/api/v1/ws", path: "/api/v1/ws", expectedClass: "CLIENT_API" }, + + { + name: "/v1 alias", + path: "/v1", + expectedClass: "CLIENT_API", + expectedNormalized: "/api/v1", + }, + { + name: "/v1/chat/completions alias", + path: "/v1/chat/completions", + expectedClass: "CLIENT_API", + expectedNormalized: "/api/v1/chat/completions", + }, + { + name: "/v1/v1 double-prefix", + path: "/v1/v1", + expectedClass: "CLIENT_API", + expectedNormalized: "/api/v1", + }, + { + name: "/v1/v1/embeddings double-prefix", + path: "/v1/v1/embeddings", + expectedClass: "CLIENT_API", + expectedNormalized: "/api/v1/embeddings", + }, + { + name: "/chat/completions alias", + path: "/chat/completions", + expectedClass: "CLIENT_API", + expectedNormalized: "/api/v1/chat/completions", + }, + { + name: "/responses alias", + path: "/responses", + expectedClass: "CLIENT_API", + expectedNormalized: "/api/v1/responses", + }, + { + name: "/responses/abc alias", + path: "/responses/abc", + expectedClass: "CLIENT_API", + expectedNormalized: "/api/v1/responses/abc", + }, + { + name: "/codex/* alias collapses to /api/v1/responses", + path: "/codex/anything", + expectedClass: "CLIENT_API", + expectedNormalized: "/api/v1/responses", + }, + { + name: "/models alias", + path: "/models", + expectedClass: "CLIENT_API", + expectedNormalized: "/api/v1/models", + }, + + { + name: "/api/auth/login is PUBLIC", + path: "/api/auth/login", + method: "POST", + expectedClass: "PUBLIC", + }, + { + name: "/api/auth/logout is PUBLIC", + path: "/api/auth/logout", + method: "POST", + expectedClass: "PUBLIC", + }, + { + name: "/api/auth/status is PUBLIC", + path: "/api/auth/status", + method: "GET", + expectedClass: "PUBLIC", + }, + { name: "/api/init is PUBLIC", path: "/api/init", method: "POST", expectedClass: "PUBLIC" }, + { + name: "/api/monitoring/health is PUBLIC", + path: "/api/monitoring/health", + method: "GET", + expectedClass: "PUBLIC", + }, + { + name: "/api/cloud/* is PUBLIC", + path: "/api/cloud/something", + method: "GET", + expectedClass: "PUBLIC", + }, + { + name: "/api/oauth/* is PUBLIC", + path: "/api/oauth/callback", + method: "GET", + expectedClass: "PUBLIC", + }, + { + name: "/api/sync/bundle is PUBLIC", + path: "/api/sync/bundle", + method: "POST", + expectedClass: "PUBLIC", + }, + { + name: "/api/settings/require-login GET is PUBLIC readonly", + path: "/api/settings/require-login", + method: "GET", + expectedClass: "PUBLIC", + }, + { + name: "/api/settings/require-login POST is MANAGEMENT", + path: "/api/settings/require-login", + method: "POST", + expectedClass: "MANAGEMENT", + }, + + { + name: "/api/providers/* MUST stay MANAGEMENT", + path: "/api/providers/openai", + expectedClass: "MANAGEMENT", + }, + { name: "/api/keys MANAGEMENT", path: "/api/keys", expectedClass: "MANAGEMENT" }, + { name: "/api/settings MANAGEMENT", path: "/api/settings", expectedClass: "MANAGEMENT" }, + { name: "/api/audit MANAGEMENT", path: "/api/audit", expectedClass: "MANAGEMENT" }, + + { + name: "Unknown top-level path defaults MANAGEMENT (fail-closed)", + path: "/totally-unknown", + expectedClass: "MANAGEMENT", + }, +]; + +for (const c of cases) { + test(`classifyRoute: ${c.name}`, () => { + const r = classifyRoute(c.path, c.method ?? "GET"); + assert.equal(r.routeClass, c.expectedClass, `routeClass for ${c.path}`); + if (c.expectedNormalized) { + assert.equal(r.normalizedPath, c.expectedNormalized, `normalizedPath for ${c.path}`); + } + }); +} + +test("classifyRoute returns deterministic result for trailing slash", () => { + const a = classifyRoute("/api/v1/chat/completions", "POST"); + const b = classifyRoute("/api/v1/chat/completions/", "POST"); + assert.equal(a.routeClass, b.routeClass); + assert.equal(a.normalizedPath, b.normalizedPath); +}); + +test("classifyRoute strips trailing slash on root only when not '/'", () => { + assert.equal(classifyRoute("/").normalizedPath, "/"); +}); + +test("classifyRoute treats /api/v1 prefix exactly", () => { + assert.equal(classifyRoute("/api/v1abc").routeClass, "MANAGEMENT"); + assert.equal(classifyRoute("/api/v1/x").routeClass, "CLIENT_API"); +}); diff --git a/tests/unit/authz/client-api-policy.test.ts b/tests/unit/authz/client-api-policy.test.ts new file mode 100644 index 0000000000..54bb5eab74 --- /dev/null +++ b/tests/unit/authz/client-api-policy.test.ts @@ -0,0 +1,112 @@ +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(), "omr-clientapi-policy-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; + +const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts"); +const core = await import("../../../src/lib/db/core.ts"); + +const ORIGINAL_OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY; +const ORIGINAL_ROUTER_API_KEY = process.env.ROUTER_API_KEY; + +function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + delete process.env.OMNIROUTE_API_KEY; + delete process.env.ROUTER_API_KEY; +} + +test.beforeEach(() => { + resetStorage(); +}); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_OMNIROUTE_API_KEY === undefined) delete process.env.OMNIROUTE_API_KEY; + else process.env.OMNIROUTE_API_KEY = ORIGINAL_OMNIROUTE_API_KEY; + if (ORIGINAL_ROUTER_API_KEY === undefined) delete process.env.ROUTER_API_KEY; + else process.env.ROUTER_API_KEY = ORIGINAL_ROUTER_API_KEY; +}); + +async function loadPolicy() { + const mod = await import(`../../../src/server/authz/policies/clientApi.ts?ts=${Date.now()}`); + return mod.clientApiPolicy; +} + +function ctx(headers: Headers) { + return { + request: { method: "POST", headers }, + classification: { + routeClass: "CLIENT_API" as const, + reason: "client_api_v1" as const, + normalizedPath: "/api/v1/chat/completions", + }, + requestId: "req_test", + }; +} + +test("clientApiPolicy: missing bearer is rejected with 401", async () => { + const policy = await loadPolicy(); + const out = await policy.evaluate(ctx(new Headers())); + assert.equal(out.allow, false); + if (!out.allow) { + assert.equal(out.status, 401); + assert.equal(out.code, "AUTH_002"); + } +}); + +test("clientApiPolicy: invalid bearer is rejected with 401", async () => { + const policy = await loadPolicy(); + const headers = new Headers({ authorization: "Bearer sk-totally-bogus" }); + const out = await policy.evaluate(ctx(headers)); + assert.equal(out.allow, false); + if (!out.allow) { + assert.equal(out.status, 401); + assert.equal(out.code, "AUTH_002"); + } +}); + +test("clientApiPolicy: valid bearer is accepted as client_api_key subject", async () => { + const created = await apiKeysDb.createApiKey("policy-test-key", "machine-test-1234"); + assert.ok(created?.key, "createApiKey must return a key"); + + const policy = await loadPolicy(); + const headers = new Headers({ authorization: `Bearer ${created.key}` }); + const out = await policy.evaluate(ctx(headers)); + assert.equal(out.allow, true); + if (out.allow) { + assert.equal(out.subject.kind, "client_api_key"); + assert.match(out.subject.id, /^key_/); + } +}); + +test("clientApiPolicy: revoked bearer is rejected", async () => { + const created = await apiKeysDb.createApiKey("policy-revoked-key", "machine-revoked"); + assert.ok(await apiKeysDb.revokeApiKey(created.id)); + + const policy = await loadPolicy(); + const headers = new Headers({ authorization: `Bearer ${created.key}` }); + const out = await policy.evaluate(ctx(headers)); + assert.equal(out.allow, false); +}); + +test("clientApiPolicy: environment API key remains accepted for client API routes", async () => { + process.env.OMNIROUTE_API_KEY = "sk-env-policy-test"; + + const policy = await loadPolicy(); + const out = await policy.evaluate( + ctx(new Headers({ authorization: "Bearer sk-env-policy-test" })) + ); + + assert.equal(out.allow, true); + if (out.allow) { + assert.equal(out.subject.kind, "client_api_key"); + } +}); diff --git a/tests/unit/authz/management-policy.test.ts b/tests/unit/authz/management-policy.test.ts new file mode 100644 index 0000000000..7062001b83 --- /dev/null +++ b/tests/unit/authz/management-policy.test.ts @@ -0,0 +1,121 @@ +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(), "omr-mgmt-policy-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; + +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 modelSync = await import("../../../src/shared/services/modelSyncScheduler.ts"); + +const ORIGINAL_JWT = process.env.JWT_SECRET; +const ORIGINAL_INITIAL = process.env.INITIAL_PASSWORD; + +function reset() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + delete process.env.JWT_SECRET; + delete process.env.INITIAL_PASSWORD; +} + +test.beforeEach(() => { + reset(); +}); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_JWT === undefined) delete process.env.JWT_SECRET; + else process.env.JWT_SECRET = ORIGINAL_JWT; + if (ORIGINAL_INITIAL === undefined) delete process.env.INITIAL_PASSWORD; + else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL; +}); + +async function loadPolicy() { + const mod = await import(`../../../src/server/authz/policies/management.ts?ts=${Date.now()}`); + return mod.managementPolicy; +} + +function ctx(headers: Headers, method = "GET", path = "/api/keys") { + return { + request: { method, headers, url: `http://localhost${path}`, nextUrl: { pathname: path } }, + classification: { + routeClass: "MANAGEMENT" as const, + reason: path.startsWith("/dashboard") + ? ("dashboard_prefix" as const) + : ("management_api" as const), + normalizedPath: path, + }, + requestId: "req_test", + }; +} + +test("managementPolicy: allows when auth not required (no password set)", async () => { + await settingsDb.updateSettings({ requireLogin: true, password: null }); + const policy = await loadPolicy(); + const out = await policy.evaluate(ctx(new Headers())); + assert.equal(out.allow, true); + if (out.allow) { + assert.equal(out.subject.kind, "anonymous"); + assert.equal(out.subject.label, "auth-disabled"); + } +}); + +test("managementPolicy: rejects 401 when auth required and no credentials", async () => { + process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy"; + process.env.INITIAL_PASSWORD = "initial-pass"; + await settingsDb.updateSettings({ requireLogin: true }); + + const policy = await loadPolicy(); + const out = await policy.evaluate(ctx(new Headers())); + assert.equal(out.allow, false); + if (!out.allow) { + assert.equal(out.status, 401); + assert.equal(out.code, "AUTH_001"); + } +}); + +test("managementPolicy: rejects client API keys for dashboard access", async () => { + process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy"; + process.env.INITIAL_PASSWORD = "initial-pass"; + await settingsDb.updateSettings({ requireLogin: true }); + const created = await apiKeysDb.createApiKey("dashboard-denied", "machine-dashboard-denied"); + + const policy = await loadPolicy(); + const out = await policy.evaluate( + ctx(new Headers({ authorization: `Bearer ${created.key}` }), "GET", "/dashboard") + ); + + assert.equal(out.allow, false); + if (!out.allow) { + assert.equal(out.status, 403); + assert.equal(out.code, "AUTH_001"); + } +}); + +test("managementPolicy: allows internal model sync only on the dedicated provider routes", async () => { + process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy"; + process.env.INITIAL_PASSWORD = "initial-pass"; + await settingsDb.updateSettings({ requireLogin: true }); + + const policy = await loadPolicy(); + const internalHeaders = new Headers(modelSync.buildModelSyncInternalHeaders()); + + const allowed = await policy.evaluate( + ctx(internalHeaders, "POST", "/api/providers/conn-123/sync-models") + ); + assert.equal(allowed.allow, true); + if (allowed.allow) { + assert.equal(allowed.subject.kind, "management_key"); + assert.equal(allowed.subject.id, "model-sync"); + } + + const denied = await policy.evaluate(ctx(internalHeaders, "POST", "/api/keys")); + assert.equal(denied.allow, false); +}); diff --git a/tests/unit/authz/pipeline.test.ts b/tests/unit/authz/pipeline.test.ts new file mode 100644 index 0000000000..5b42f2f91a --- /dev/null +++ b/tests/unit/authz/pipeline.test.ts @@ -0,0 +1,141 @@ +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 { SignJWT } from "jose"; +import { NextRequest } from "next/server"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-authz-pipeline-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; + +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 pipeline = await import("../../../src/server/authz/pipeline.ts"); + +const ORIGINAL_JWT = process.env.JWT_SECRET; +const ORIGINAL_INITIAL = process.env.INITIAL_PASSWORD; +const ORIGINAL_AUTH_COOKIE_SECURE = process.env.AUTH_COOKIE_SECURE; + +function resetEnvironment() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + process.env.JWT_SECRET = "pipeline-jwt-secret"; + process.env.INITIAL_PASSWORD = "pipeline-initial-password"; + delete process.env.AUTH_COOKIE_SECURE; + globalThis.__omnirouteShutdown = { init: false, shuttingDown: false, activeRequests: 0 }; +} + +async function forceAuthRequired() { + await settingsDb.updateSettings({ requireLogin: true }); +} + +function request(url: string, init?: RequestInit): NextRequest { + return new NextRequest(url, init); +} + +test.beforeEach(() => { + resetEnvironment(); +}); + +test.after(() => { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_JWT === undefined) delete process.env.JWT_SECRET; + else process.env.JWT_SECRET = ORIGINAL_JWT; + if (ORIGINAL_INITIAL === undefined) delete process.env.INITIAL_PASSWORD; + else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL; + if (ORIGINAL_AUTH_COOKIE_SECURE === undefined) delete process.env.AUTH_COOKIE_SECURE; + else process.env.AUTH_COOKIE_SECURE = ORIGINAL_AUTH_COOKIE_SECURE; + globalThis.__omnirouteShutdown = { init: false, shuttingDown: false, activeRequests: 0 }; +}); + +test("runAuthzPipeline redirects root to dashboard before management auth", async () => { + await forceAuthRequired(); + + const response = await pipeline.runAuthzPipeline(request("http://localhost/"), { enforce: true }); + + assert.equal(response.status, 307); + assert.equal(response.headers.get("location"), "http://localhost/dashboard"); +}); + +test("runAuthzPipeline redirects unauthenticated dashboard pages to login", async () => { + await forceAuthRequired(); + + const response = await pipeline.runAuthzPipeline(request("http://localhost/dashboard"), { + enforce: true, + }); + + assert.equal(response.status, 307); + assert.equal(response.headers.get("location"), "http://localhost/login"); + assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT"); + assert.ok(response.headers.get("x-request-id")); +}); + +test("runAuthzPipeline keeps management API rejections as JSON", async () => { + await forceAuthRequired(); + + const response = await pipeline.runAuthzPipeline(request("http://localhost/api/keys"), { + enforce: true, + }); + const body = await response.json(); + + assert.equal(response.status, 401); + assert.equal(response.headers.get("content-type")?.includes("application/json"), true); + assert.equal(body.error.code, "AUTH_001"); +}); + +test("runAuthzPipeline rejects oversized API bodies before auth", async () => { + const response = await pipeline.runAuthzPipeline( + request("http://localhost/api/v1/chat/completions", { + method: "POST", + headers: { + "content-length": String(99 * 1024 * 1024), + origin: "https://app.example.com", + }, + }), + { enforce: true } + ); + + assert.equal(response.status, 413); + assert.equal(response.headers.get("x-omniroute-route-class"), "CLIENT_API"); + assert.ok(response.headers.get("x-request-id")); + assert.equal( + response.headers.get("Access-Control-Allow-Methods"), + "GET, POST, PUT, DELETE, PATCH, OPTIONS" + ); +}); + +test("runAuthzPipeline rejects new API requests during shutdown drain", async () => { + globalThis.__omnirouteShutdown = { init: true, shuttingDown: true, activeRequests: 0 }; + + const response = await pipeline.runAuthzPipeline(request("http://localhost/api/v1/models"), { + enforce: true, + }); + const body = await response.json(); + + assert.equal(response.status, 503); + assert.equal(body.error.code, "SERVICE_UNAVAILABLE"); +}); + +test("runAuthzPipeline refreshes dashboard JWTs near expiry", async () => { + await forceAuthRequired(); + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const expiringToken = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(secret); + + const response = await pipeline.runAuthzPipeline( + request("http://localhost/dashboard", { + headers: { cookie: `auth_token=${expiringToken}` }, + }), + { enforce: true } + ); + + assert.equal(response.status, 200); + assert.match(response.headers.get("set-cookie") || "", /auth_token=/); +}); diff --git a/tests/unit/authz/public-policy.test.ts b/tests/unit/authz/public-policy.test.ts new file mode 100644 index 0000000000..1ebffc963b --- /dev/null +++ b/tests/unit/authz/public-policy.test.ts @@ -0,0 +1,22 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { publicPolicy } from "../../../src/server/authz/policies/public.ts"; +import type { PolicyContext } from "../../../src/server/authz/context.ts"; + +function ctx(): PolicyContext { + return { + request: { method: "GET", headers: new Headers() }, + classification: { routeClass: "PUBLIC", reason: "public_prefix", normalizedPath: "/api/init" }, + requestId: "req_test", + }; +} + +test("publicPolicy always allows with anonymous subject", async () => { + const out = await publicPolicy.evaluate(ctx()); + assert.equal(out.allow, true); + if (out.allow) { + assert.equal(out.subject.kind, "anonymous"); + assert.equal(out.subject.id, "anonymous"); + } +}); diff --git a/tests/unit/batch_api.test.ts b/tests/unit/batch_api.test.ts index 97b8863f62..afb9103db7 100644 --- a/tests/unit/batch_api.test.ts +++ b/tests/unit/batch_api.test.ts @@ -696,68 +696,6 @@ test("Batch processor fails orphaned finalizing batches during startup recovery" } }); -test("Batch list route rejects missing API key when REQUIRE_API_KEY is enabled", async () => { - const previous = process.env.REQUIRE_API_KEY; - process.env.REQUIRE_API_KEY = "true"; - - try { - const response = await batchesRoute.GET(new Request("http://localhost/api/v1/batches")); - const json = await response.json(); - - assert.strictEqual(response.status, 401); - assert.strictEqual(json.error.message, "Missing API key"); - } finally { - process.env.REQUIRE_API_KEY = previous ?? "false"; - } -}); - -test("Files list route rejects invalid API key when REQUIRE_API_KEY is enabled", async () => { - const previous = process.env.REQUIRE_API_KEY; - process.env.REQUIRE_API_KEY = "true"; - - try { - const response = await filesRoute.GET( - new Request("http://localhost/api/v1/files", { - headers: { Authorization: "Bearer invalid-test-key" }, - }) - ); - const json = await response.json(); - - assert.strictEqual(response.status, 401); - assert.strictEqual(json.error.message, "Invalid API key"); - } finally { - process.env.REQUIRE_API_KEY = previous ?? "false"; - } -}); - -test("Files upload route rejects invalid API key even when auth is optional", async () => { - const previous = process.env.REQUIRE_API_KEY; - process.env.REQUIRE_API_KEY = "false"; - - try { - const formData = new FormData(); - formData.set("purpose", "batch"); - formData.set( - "file", - new File([Buffer.from('{"ok":true}\n')], "input.jsonl", { type: "application/json" }) - ); - - const response = await filesRoute.POST( - new Request("http://localhost/api/v1/files", { - method: "POST", - headers: { Authorization: "Bearer invalid-test-key" }, - body: formData, - }) - ); - const json = await response.json(); - - assert.strictEqual(response.status, 401); - assert.strictEqual(json.error.message, "Invalid API key"); - } finally { - process.env.REQUIRE_API_KEY = previous ?? "false"; - } -}); - test("Files upload route stores multipart content", async () => { const fileContent = '{"custom_id":"req-1"}\n'; const formData = new FormData(); @@ -794,7 +732,7 @@ test("Files and batches routes expose explicit CORS preflight handlers", async ( assert.strictEqual(typeof route.OPTIONS, "function"); const response = await route.OPTIONS(); assert.strictEqual(response.status, 204); - assert.strictEqual(response.headers.get("Access-Control-Allow-Origin"), "*"); + assert.strictEqual(response.headers.get("Access-Control-Allow-Origin"), null); assert.match( String(response.headers.get("Access-Control-Allow-Headers") || ""), /Authorization/i diff --git a/tests/unit/chat-cooldown-aware-retry.test.ts b/tests/unit/chat-cooldown-aware-retry.test.ts index cab0138158..1ab266a799 100644 --- a/tests/unit/chat-cooldown-aware-retry.test.ts +++ b/tests/unit/chat-cooldown-aware-retry.test.ts @@ -49,7 +49,7 @@ test.after(async () => { test("handleChat waits for a short cooldown and retries once within the configured budget", async () => { await seedConnection("openai", { apiKey: "sk-openai-cooldown-short", - rateLimitedUntil: new Date(Date.now() + 350).toISOString(), + rateLimitedUntil: new Date(Date.now() + 950).toISOString(), lastError: "short cooldown window", errorCode: 429, }); diff --git a/tests/unit/chat-route-coverage.test.ts b/tests/unit/chat-route-coverage.test.ts index 2a0a50468e..471b1a6ac0 100644 --- a/tests/unit/chat-route-coverage.test.ts +++ b/tests/unit/chat-route-coverage.test.ts @@ -163,33 +163,6 @@ test("handleChat treats Accept text/event-stream as stream=true and returns a se assert.match(raw, /\[DONE\]/); }); -test("handleChat enforces strict API key mode for missing and invalid keys", async () => { - process.env.REQUIRE_API_KEY = "true"; - - const missing = await handleChat( - buildRequest({ - body: { - model: "openai/gpt-4o-mini", - stream: false, - messages: [{ role: "user", content: "missing auth" }], - }, - }) - ); - const invalid = await handleChat( - buildRequest({ - authKey: "sk-does-not-exist", - body: { - model: "openai/gpt-4o-mini", - stream: false, - messages: [{ role: "user", content: "invalid auth" }], - }, - }) - ); - - assert.equal(missing.status, 401); - assert.equal(invalid.status, 401); -}); - test("handleChat rejects requests without a model", async () => { const response = await handleChat( buildRequest({ diff --git a/tests/unit/cors/origins.test.ts b/tests/unit/cors/origins.test.ts new file mode 100644 index 0000000000..ded9e46366 --- /dev/null +++ b/tests/unit/cors/origins.test.ts @@ -0,0 +1,154 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { NextResponse } from "next/server"; +import { + applyCorsHeaders, + resolveAllowedOrigin, + setRuntimeAllowedOrigins, + STATIC_CORS_HEADERS, +} from "../../../src/server/cors/origins"; + +const ENV_KEYS = ["CORS_ALLOW_ALL", "CORS_ALLOWED_ORIGINS", "CORS_ORIGIN"] as const; + +function snapshotEnv(): Record { + const snap: Record = {}; + for (const key of ENV_KEYS) snap[key] = process.env[key]; + return snap; +} + +function restoreEnv(snap: Record) { + for (const key of ENV_KEYS) { + if (snap[key] === undefined) delete process.env[key]; + else process.env[key] = snap[key]; + } +} + +describe("cors/origins.resolveAllowedOrigin", () => { + let envSnap: Record; + + beforeEach(() => { + envSnap = snapshotEnv(); + for (const key of ENV_KEYS) delete process.env[key]; + setRuntimeAllowedOrigins(""); + }); + + afterEach(() => { + restoreEnv(envSnap); + setRuntimeAllowedOrigins(""); + }); + + it("returns null by default — fail-closed without explicit allowlist", () => { + assert.equal(resolveAllowedOrigin("https://app.example.com"), null); + }); + + it("returns null when origin header is absent", () => { + process.env.CORS_ALLOWED_ORIGINS = "https://app.example.com"; + assert.equal(resolveAllowedOrigin(null), null); + assert.equal(resolveAllowedOrigin(undefined), null); + }); + + it("echoes origin when env allowlist matches (case-insensitive, trailing slash ignored)", () => { + process.env.CORS_ALLOWED_ORIGINS = "https://app.example.com, https://Admin.Example.com/"; + assert.equal(resolveAllowedOrigin("https://app.example.com"), "https://app.example.com"); + assert.equal(resolveAllowedOrigin("https://admin.example.com"), "https://admin.example.com"); + assert.equal(resolveAllowedOrigin("https://other.example.com"), null); + }); + + it("merges env allowlist and runtime allowlist", () => { + process.env.CORS_ALLOWED_ORIGINS = "https://env.example.com"; + setRuntimeAllowedOrigins("https://runtime.example.com"); + assert.equal(resolveAllowedOrigin("https://env.example.com"), "https://env.example.com"); + assert.equal( + resolveAllowedOrigin("https://runtime.example.com"), + "https://runtime.example.com" + ); + assert.equal(resolveAllowedOrigin("https://other.example.com"), null); + }); + + it("CORS_ALLOW_ALL=true echoes any origin (with Vary applied later)", () => { + process.env.CORS_ALLOW_ALL = "true"; + assert.equal( + resolveAllowedOrigin("https://anything.example.com"), + "https://anything.example.com" + ); + }); + + it("CORS_ALLOW_ALL falls back to '*' when no Origin header is present", () => { + process.env.CORS_ALLOW_ALL = "1"; + assert.equal(resolveAllowedOrigin(null), "*"); + }); + + it("legacy CORS_ORIGIN=* behaves like CORS_ALLOW_ALL", () => { + process.env.CORS_ORIGIN = "*"; + assert.equal( + resolveAllowedOrigin("https://anything.example.com"), + "https://anything.example.com" + ); + }); + + it("legacy CORS_ORIGIN= is added to env allowlist", () => { + process.env.CORS_ORIGIN = "https://legacy.example.com"; + assert.equal(resolveAllowedOrigin("https://legacy.example.com"), "https://legacy.example.com"); + assert.equal(resolveAllowedOrigin("https://other.example.com"), null); + }); +}); + +describe("cors/origins.applyCorsHeaders", () => { + let envSnap: Record; + + beforeEach(() => { + envSnap = snapshotEnv(); + for (const key of ENV_KEYS) delete process.env[key]; + setRuntimeAllowedOrigins(""); + }); + + afterEach(() => { + restoreEnv(envSnap); + setRuntimeAllowedOrigins(""); + }); + + it("does not set Allow-Origin when origin is not in allowlist", () => { + const res = NextResponse.json({ ok: true }); + const req = new Request("https://server.example.com/api/v1/chat/completions", { + headers: { Origin: "https://evil.example.com" }, + }); + applyCorsHeaders(res, req); + assert.equal(res.headers.get("Access-Control-Allow-Origin"), null); + assert.match(res.headers.get("Access-Control-Allow-Methods") || "", /OPTIONS/); + }); + + it("echoes origin and sets Vary: Origin when origin is allowed", () => { + process.env.CORS_ALLOWED_ORIGINS = "https://app.example.com"; + const res = NextResponse.json({ ok: true }); + const req = new Request("https://server.example.com/api/v1/chat/completions", { + headers: { Origin: "https://app.example.com" }, + }); + applyCorsHeaders(res, req); + assert.equal(res.headers.get("Access-Control-Allow-Origin"), "https://app.example.com"); + assert.match(res.headers.get("Vary") || "", /Origin/); + }); + + it("reflects requested headers from Access-Control-Request-Headers preflight", () => { + process.env.CORS_ALLOWED_ORIGINS = "https://app.example.com"; + const res = NextResponse.json({ ok: true }); + const req = new Request("https://server.example.com/api/v1/chat/completions", { + method: "OPTIONS", + headers: { + Origin: "https://app.example.com", + "Access-Control-Request-Headers": "x-custom-header, authorization", + }, + }); + applyCorsHeaders(res, req); + assert.equal(res.headers.get("Access-Control-Allow-Headers"), "x-custom-header, authorization"); + }); +}); + +describe("cors/origins.STATIC_CORS_HEADERS", () => { + it("never contains Access-Control-Allow-Origin", () => { + assert.equal( + Object.prototype.hasOwnProperty.call(STATIC_CORS_HEADERS, "Access-Control-Allow-Origin"), + false + ); + assert.match(STATIC_CORS_HEADERS["Access-Control-Allow-Methods"], /OPTIONS/); + }); +}); diff --git a/tests/unit/db-migration-runner.test.ts b/tests/unit/db-migration-runner.test.ts index eae563eb0c..654ccb0da3 100644 --- a/tests/unit/db-migration-runner.test.ts +++ b/tests/unit/db-migration-runner.test.ts @@ -203,6 +203,90 @@ test("runMigrations skips versions that are already tracked as applied", serial, } }); +test( + "runMigrations applies api key lifecycle migration idempotently when columns already exist", + serial, + async () => { + const runner = await importFresh("src/lib/db/migrationRunner.ts"); + const db = createDb(); + + try { + db.exec(` + CREATE TABLE api_keys ( + id TEXT PRIMARY KEY, + key TEXT NOT NULL, + revoked_at TEXT + ); + `); + + const appliedCount = withMockedMigrationFs( + { + "032_apikey_lifecycle.sql": "ALTER TABLE api_keys ADD COLUMN revoked_at TEXT;", + }, + () => runner.runMigrations(db) + ); + + assert.equal(appliedCount, 1); + const columns = db.prepare("PRAGMA table_info(api_keys)").all() as Array<{ name: string }>; + const names = new Set(columns.map((column) => column.name)); + for (const expected of [ + "revoked_at", + "expires_at", + "last_used_at", + "key_prefix", + "ip_allowlist", + "scopes", + ]) { + assert.equal(names.has(expected), true, `${expected} should exist`); + } + assert.deepEqual( + db.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ?").get("032"), + { version: "032", name: "apikey_lifecycle" } + ); + } finally { + db.close(); + } + } +); + +test( + "runMigrations applies api key lifecycle hardening by version even if filename suffix changes", + serial, + async () => { + const runner = await importFresh("src/lib/db/migrationRunner.ts"); + const db = createDb(); + + try { + db.exec(` + CREATE TABLE api_keys ( + id TEXT PRIMARY KEY, + key TEXT NOT NULL + ); + `); + + const appliedCount = withMockedMigrationFs( + { + "032_renamed_lifecycle_patch.sql": "ALTER TABLE api_keys ADD COLUMN should_not_run TEXT;", + }, + () => runner.runMigrations(db) + ); + + assert.equal(appliedCount, 1); + const columns = db.prepare("PRAGMA table_info(api_keys)").all() as Array<{ name: string }>; + const names = new Set(columns.map((column) => column.name)); + assert.equal(names.has("revoked_at"), true); + assert.equal(names.has("expires_at"), true); + assert.equal(names.has("should_not_run"), false); + assert.deepEqual( + db.prepare("SELECT version, name FROM _omniroute_migrations WHERE version = ?").get("032"), + { version: "032", name: "renamed_lifecycle_patch" } + ); + } finally { + db.close(); + } + } +); + test("getMigrationStatus reports applied and pending migrations", serial, async () => { const runner = await importFresh("src/lib/db/migrationRunner.ts"); const db = createDb(); diff --git a/tests/unit/model-sync-scheduler.test.ts b/tests/unit/model-sync-scheduler.test.ts index ecdadf22d3..1204666683 100644 --- a/tests/unit/model-sync-scheduler.test.ts +++ b/tests/unit/model-sync-scheduler.test.ts @@ -171,14 +171,6 @@ test("initCloudSync skips auto initialization during build and test processes un ); }); -test("proxy: internal model sync token is only allowed for provider model sync routes", () => { - const filePath = path.join(process.cwd(), "src/proxy.ts"); - const source = fs.readFileSync(filePath, "utf8"); - - assert.match(source, /isModelSyncInternalRequest/); - assert.match(source, /sync-models\|models/); -}); - test("modelSyncScheduler starts once, honors env interval and syncs only active autoSync connections", async () => { await providersDb.createProviderConnection({ provider: "openai", diff --git a/tests/unit/moderations-handler.test.ts b/tests/unit/moderations-handler.test.ts index f3b5378b7e..06cff14788 100644 --- a/tests/unit/moderations-handler.test.ts +++ b/tests/unit/moderations-handler.test.ts @@ -70,7 +70,8 @@ test("handleModeration proxies successful requests with default model and access input: "all clear", }); assert.equal(response.status, 200); - assert.equal(response.headers.get("access-control-allow-origin"), "*"); + assert.equal(response.headers.get("access-control-allow-origin"), null); + assert.match(response.headers.get("access-control-allow-methods") || "", /OPTIONS/); assert.deepEqual(await response.json(), { id: "modr-1", results: [{ flagged: false }], @@ -92,7 +93,8 @@ test("handleModeration returns upstream error payloads with CORS headers", async assert.equal(response.status, 429); assert.equal(await response.text(), '{"error":"busy"}'); assert.equal(response.headers.get("content-type"), "application/json"); - assert.equal(response.headers.get("access-control-allow-origin"), "*"); + assert.equal(response.headers.get("access-control-allow-origin"), null); + assert.match(response.headers.get("access-control-allow-methods") || "", /OPTIONS/); }); test("handleModeration returns a 500 when the upstream request throws", async () => { diff --git a/tests/unit/proxy-e2e-mode.test.ts b/tests/unit/proxy-e2e-mode.test.ts deleted file mode 100644 index d5bd82b2da..0000000000 --- a/tests/unit/proxy-e2e-mode.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; - -const originalEnv = { - NEXT_PUBLIC_OMNIROUTE_E2E_MODE: process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE, -}; - -function restoreEnv() { - for (const [key, value] of Object.entries(originalEnv)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } -} - -async function importFresh(modulePath) { - const url = pathToFileURL(path.resolve(modulePath)).href; - return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`); -} - -function makeRequest(pathname) { - return { - nextUrl: { - pathname, - protocol: "http:", - }, - method: "GET", - cookies: { - get() { - return undefined; - }, - }, - headers: new Headers(), - url: `http://localhost:20128${pathname}`, - }; -} - -test.beforeEach(() => { - restoreEnv(); -}); - -test.afterEach(() => { - restoreEnv(); -}); - -test.after(() => { - restoreEnv(); -}); - -test("proxy bypasses dashboard auth in Playwright e2e mode", async () => { - process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE = "1"; - - const { proxy } = await importFresh("src/proxy.ts"); - const response = await proxy(makeRequest("/dashboard/combos")); - - assert.equal(response.status, 200); - assert.equal(response.headers.get("location"), null); - assert.ok(response.headers.get("x-request-id")); -}); - -test("proxy bypasses management API auth in Playwright e2e mode", async () => { - process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE = "1"; - - const { proxy } = await importFresh("src/proxy.ts"); - const response = await proxy(makeRequest("/api/combos")); - - assert.equal(response.status, 200); - assert.equal(response.headers.get("location"), null); - assert.ok(response.headers.get("x-request-id")); -}); diff --git a/tests/unit/proxy-login-bootstrap-auth.test.ts b/tests/unit/proxy-login-bootstrap-auth.test.ts deleted file mode 100644 index d637679d68..0000000000 --- a/tests/unit/proxy-login-bootstrap-auth.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -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 { pathToFileURL } from "node:url"; - -const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-bootstrap-")); -process.env.DATA_DIR = TEST_DATA_DIR; - -const core = await import("../../src/lib/db/core.ts"); -const settingsDb = await import("../../src/lib/db/settings.ts"); - -async function importFreshProxy() { - const url = pathToFileURL(path.resolve("src/proxy.ts")).href; - return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`); -} - -async function resetStorage() { - core.resetDbInstance(); - fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); - fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); -} - -function makeRequest(pathname, method = "GET", headers) { - return { - nextUrl: { - pathname, - protocol: "http:", - }, - method, - cookies: { - get() { - return undefined; - }, - }, - headers: new Headers(headers), - url: `http://localhost:20128${pathname}`, - }; -} - -test.beforeEach(async () => { - delete process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE; - delete process.env.INITIAL_PASSWORD; - await resetStorage(); -}); - -test.after(() => { - delete process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE; - delete process.env.INITIAL_PASSWORD; - core.resetDbInstance(); - fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); -}); - -test("proxy keeps GET /api/settings/require-login public after setup", async () => { - await settingsDb.updateSettings({ - requireLogin: true, - password: "hashed-password", - setupComplete: true, - }); - - const { proxy } = await importFreshProxy(); - const response = await proxy(makeRequest("/api/settings/require-login")); - - assert.equal(response.status, 200); - assert.equal(response.headers.get("location"), null); - assert.ok(response.headers.get("x-request-id")); -}); - -test("proxy requires auth for POST /api/settings/require-login after setup", async () => { - await settingsDb.updateSettings({ - requireLogin: true, - password: "hashed-password", - setupComplete: true, - }); - - const { proxy } = await importFreshProxy(); - const response = await proxy(makeRequest("/api/settings/require-login", "POST")); - const body = (await response.json()) as any; - - assert.equal(response.status, 401); - assert.equal(body.error.code, "AUTH_001"); -}); - -test("proxy rejects bearer tokens for POST /api/settings/require-login after setup", async () => { - await settingsDb.updateSettings({ - requireLogin: true, - password: "hashed-password", - setupComplete: true, - }); - - const { proxy } = await importFreshProxy(); - const response = await proxy( - makeRequest("/api/settings/require-login", "POST", { - authorization: "Bearer sk-invalid", - }) - ); - const body = (await response.json()) as any; - - assert.equal(response.status, 403); - assert.equal(body.error.code, "AUTH_001"); -}); - -test("proxy still allows POST /api/settings/require-login during bootstrap", async () => { - const { proxy } = await importFreshProxy(); - const response = await proxy(makeRequest("/api/settings/require-login", "POST")); - - assert.equal(response.status, 200); - assert.equal(response.headers.get("location"), null); - assert.ok(response.headers.get("x-request-id")); -}); diff --git a/tests/unit/remote-image-fetch.test.ts b/tests/unit/remote-image-fetch.test.ts new file mode 100644 index 0000000000..56f7b80e1a --- /dev/null +++ b/tests/unit/remote-image-fetch.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; + +test("fetchRemoteImage reads public image bytes", async () => { + const result = await fetchRemoteImage("https://cdn.example.com/image.png", { + fetchImpl: async () => + new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { "content-type": "image/png" }, + }), + guard: "public-only", + }); + + assert.equal(result.buffer.toString("base64"), "AQID"); + assert.equal(result.contentType, "image/png"); +}); + +test("fetchRemoteImage blocks private image hosts before fetch", async () => { + let called = false; + + await assert.rejects( + () => + fetchRemoteImage("http://127.0.0.1:20128/private.png", { + fetchImpl: async () => { + called = true; + return new Response("unexpected"); + }, + guard: "public-only", + }), + /Blocked private or local provider URL/ + ); + + assert.equal(called, false); +}); + +test("fetchRemoteImage blocks redirects to private image hosts", async () => { + await assert.rejects( + () => + fetchRemoteImage("https://cdn.example.com/redirect.png", { + fetchImpl: async () => + new Response(null, { + status: 302, + headers: { location: "http://169.254.169.254/latest/meta-data" }, + }), + guard: "public-only", + }), + /Blocked private or local provider URL/ + ); +}); diff --git a/tests/unit/route-edge-coverage.test.ts b/tests/unit/route-edge-coverage.test.ts index 53ab66ce94..87380832a7 100644 --- a/tests/unit/route-edge-coverage.test.ts +++ b/tests/unit/route-edge-coverage.test.ts @@ -660,7 +660,8 @@ test("embeddings route covers options, custom-model listing and defensive POST b const validationFailureBody = (await validationFailure.json()) as any; const invalidModelBody = (await invalidModel.json()) as any; - assert.equal(optionsHeaders["access-control-allow-origin"], "*"); + assert.equal(optionsHeaders["access-control-allow-origin"], undefined); + assert.match(optionsHeaders["access-control-allow-methods"] || "", /OPTIONS/); assert.equal(getResponse.status, 200); assert.equal( getBody.data.some((model) => model.id === "custom-embedder/text-embed-1"), @@ -677,27 +678,7 @@ test("embeddings route covers options, custom-model listing and defensive POST b ); }); -test("embeddings route enforces caller auth, missing credentials and provider rate limits", async () => { - process.env.REQUIRE_API_KEY = "true"; - - const missingKey = await embeddingsRoute.POST( - makeRequest("http://localhost/v1/embeddings", { - method: "POST", - body: { model: "openai/text-embedding-3-small", input: "hello" }, - }) - ); - - const invalidKey = await embeddingsRoute.POST( - new Request("http://localhost/v1/embeddings", { - method: "POST", - headers: { - "content-type": "application/json", - authorization: "Bearer sk-invalid", - }, - body: JSON.stringify({ model: "openai/text-embedding-3-small", input: "hello" }), - }) - ); - +test("embeddings route surfaces missing-credentials and provider-rate-limit errors", async () => { const validApiKey = await apiKeysDb.createApiKey("caller", MACHINE_ID); const missingCredentials = await embeddingsRoute.POST( new Request("http://localhost/v1/embeddings", { @@ -726,15 +707,9 @@ test("embeddings route enforces caller auth, missing credentials and provider ra }) ); - const missingKeyBody = (await missingKey.json()) as any; - const invalidKeyBody = (await invalidKey.json()) as any; const missingCredentialsBody = (await missingCredentials.json()) as any; const allRateLimitedBody = (await allRateLimited.json()) as any; - assert.equal(missingKey.status, 401); - assert.equal(missingKeyBody.error.message, "Missing API key"); - assert.equal(invalidKey.status, 401); - assert.equal(invalidKeyBody.error.message, "Invalid API key"); assert.equal(missingCredentials.status, 400); assert.match(missingCredentialsBody.error.message, /No credentials for embedding provider/); assert.equal(allRateLimited.status, 429); diff --git a/tests/unit/t20-t22-provider-headers.test.ts b/tests/unit/t20-t22-provider-headers.test.ts index 0b6be2972b..2355cefbc0 100644 --- a/tests/unit/t20-t22-provider-headers.test.ts +++ b/tests/unit/t20-t22-provider-headers.test.ts @@ -14,7 +14,7 @@ test("T20: antigravity config has updated User-Agent and sandbox fallback URL", assert.equal(antigravity.headers["User-Agent"], antigravityUserAgent()); }); -test("T20: gemini CLI fingerprint uses 0.31.0 and preserves darwin platform name", () => { +test("T20: gemini CLI fingerprint uses 0.31.0 and normalizes darwin to macos", () => { assert.equal(GEMINI_CLI_VERSION, "0.31.0"); const descriptor = Object.getOwnPropertyDescriptor(process, "platform"); @@ -22,7 +22,7 @@ test("T20: gemini CLI fingerprint uses 0.31.0 and preserves darwin platform name try { assert.match( geminiCLIUserAgent("gemini-3-flash"), - /^GeminiCLI\/0\.31\.0\/gemini-3-flash \(darwin; / + /^GeminiCLI\/0\.31\.0\/gemini-3-flash \(macos; / ); } finally { if (descriptor) {