From f9e799d089814940dd1ed7fe0f4bf18751c2114e Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 25 Apr 2026 16:45:59 -0300 Subject: [PATCH] fix(security): harden management API auth and openapi try proxy Require management authentication across combo, settings, skill, webhook, provider auth, restart, and shutdown management routes to prevent unauthenticated access to privileged operations. Tighten the OpenAPI try endpoint to only proxy same-origin OmniRoute API paths and strip hop-by-hop or forwarded headers before dispatching. Add unit coverage for the new auth guards and proxy validation rules. --- src/app/api/combos/[id]/route.ts | 10 ++ src/app/api/combos/builder/options/route.ts | 6 +- src/app/api/combos/metrics/route.ts | 7 + src/app/api/combos/reorder/route.ts | 4 + src/app/api/combos/route.ts | 9 +- src/app/api/combos/test/route.ts | 4 + src/app/api/openapi/try/route.ts | 63 +++++-- .../[id]/codex-auth/apply-local/route.ts | 6 +- .../providers/[id]/codex-auth/export/route.ts | 4 + src/app/api/restart/route.ts | 6 +- .../settings/auto-disable-accounts/route.ts | 7 +- .../settings/background-degradation/route.ts | 13 +- src/app/api/settings/cache-metrics/route.ts | 9 +- src/app/api/settings/combo-defaults/route.ts | 9 +- src/app/api/settings/ip-filter/route.ts | 9 +- src/app/api/settings/model-aliases/route.ts | 17 +- .../api/settings/proxies/assignments/route.ts | 5 + .../api/settings/proxies/bulk-assign/route.ts | 4 + src/app/api/settings/proxies/health/route.ts | 4 + src/app/api/settings/proxies/migrate/route.ts | 4 + src/app/api/settings/proxies/route.ts | 9 + src/app/api/settings/proxy/route.ts | 10 ++ src/app/api/settings/proxy/test/route.ts | 4 + src/app/api/settings/system-prompt/route.ts | 9 +- src/app/api/settings/task-routing/route.ts | 9 +- src/app/api/settings/thinking-budget/route.ts | 9 +- src/app/api/shutdown/route.ts | 6 +- src/app/api/skills/[id]/route.ts | 7 + src/app/api/skills/install/route.ts | 4 + src/app/api/skills/route.ts | 4 + src/app/api/webhooks/[id]/route.ts | 10 ++ src/app/api/webhooks/[id]/test/route.ts | 4 + src/app/api/webhooks/route.ts | 9 +- tests/unit/management-auth-hardening.test.ts | 18 ++ tests/unit/openapi-try-route.test.ts | 161 ++++++++++++++++++ 35 files changed, 438 insertions(+), 35 deletions(-) create mode 100644 tests/unit/management-auth-hardening.test.ts create mode 100644 tests/unit/openapi-try-route.test.ts diff --git a/src/app/api/combos/[id]/route.ts b/src/app/api/combos/[id]/route.ts index c8f34a6e0c..299a6b7e1b 100644 --- a/src/app/api/combos/[id]/route.ts +++ b/src/app/api/combos/[id]/route.ts @@ -14,9 +14,13 @@ import { normalizeComboModels } from "@/lib/combos/steps"; import { validateComboDAG } from "@omniroute/open-sse/services/combo.ts"; import { updateComboSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; // GET /api/combos/[id] - Get combo by ID export async function GET(request, { params }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const { id } = await params; const combo = await getComboById(id); @@ -34,6 +38,9 @@ export async function GET(request, { params }) { // PUT /api/combos/[id] - Update combo export async function PUT(request, { params }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + let rawBody; try { rawBody = await request.json(); @@ -116,6 +123,9 @@ export async function PUT(request, { params }) { // DELETE /api/combos/[id] - Delete combo export async function DELETE(request, { params }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const { id } = await params; const success = await deleteCombo(id); diff --git a/src/app/api/combos/builder/options/route.ts b/src/app/api/combos/builder/options/route.ts index 82ee58195a..e852a243a6 100644 --- a/src/app/api/combos/builder/options/route.ts +++ b/src/app/api/combos/builder/options/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from "next/server"; import { getComboBuilderOptions } from "@/lib/combos/builderOptions"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; -export async function GET() { try { const options = await getComboBuilderOptions(); return NextResponse.json(options); diff --git a/src/app/api/combos/metrics/route.ts b/src/app/api/combos/metrics/route.ts index 0fa792a0ca..9266bfc7ba 100644 --- a/src/app/api/combos/metrics/route.ts +++ b/src/app/api/combos/metrics/route.ts @@ -5,9 +5,13 @@ import { resetComboMetrics, resetAllComboMetrics, } from "@omniroute/open-sse/services/comboMetrics.ts"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; // GET /api/combos/metrics - Get per-combo metrics export async function GET(request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const { searchParams } = new URL(request.url); const comboName = searchParams.get("combo"); @@ -30,6 +34,9 @@ export async function GET(request) { // DELETE /api/combos/metrics - Reset metrics export async function DELETE(request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const { searchParams } = new URL(request.url); const comboName = searchParams.get("combo"); diff --git a/src/app/api/combos/reorder/route.ts b/src/app/api/combos/reorder/route.ts index 174c87e5cd..ee66db0edd 100644 --- a/src/app/api/combos/reorder/route.ts +++ b/src/app/api/combos/reorder/route.ts @@ -4,9 +4,13 @@ import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud } from "@/lib/cloudSync"; import { reorderCombosSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; // POST /api/combos/reorder - Persist combo ordering export async function POST(request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/combos/route.ts b/src/app/api/combos/route.ts index bc71ea2d70..00731ba606 100644 --- a/src/app/api/combos/route.ts +++ b/src/app/api/combos/route.ts @@ -7,9 +7,13 @@ import { normalizeComboModels } from "@/lib/combos/steps"; import { validateComboDAG } from "@omniroute/open-sse/services/combo.ts"; import { createComboSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; // GET /api/combos - Get all combos -export async function GET() { +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const combos = await getCombos(); return NextResponse.json({ combos }); @@ -21,6 +25,9 @@ export async function GET() { // POST /api/combos - Create new combo export async function POST(request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const body = await request.json(); diff --git a/src/app/api/combos/test/route.ts b/src/app/api/combos/test/route.ts index fdee2529c0..115061f38b 100644 --- a/src/app/api/combos/test/route.ts +++ b/src/app/api/combos/test/route.ts @@ -5,6 +5,7 @@ import { 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"; function buildComboTestResult(target, partial = {}) { return { @@ -106,6 +107,9 @@ async function testComboTarget(target, baseInternalUrl) { * and only reports success when the model returns usable text content. */ export async function POST(request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/openapi/try/route.ts b/src/app/api/openapi/try/route.ts index 8df227e6d7..cd64dad52c 100644 --- a/src/app/api/openapi/try/route.ts +++ b/src/app/api/openapi/try/route.ts @@ -5,19 +5,65 @@ import { z } from "zod"; import { NextRequest, NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +const ALLOWED_TRY_PATH_PREFIXES = ["/api/", "/v1/", "/v1beta/", "/a2a", "/.well-known/agent.json"]; +const BLOCKED_FORWARD_HEADERS = new Set([ + "connection", + "content-length", + "cookie", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", +]); + const tryRequestSchema = z.object({ method: z .enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]) .optional() .default("GET"), - path: z.string().min(1, "Path is required").startsWith("/", "Path must start with /"), + path: z + .string() + .min(1, "Path is required") + .startsWith("/", "Path must start with /") + .refine((value) => !value.startsWith("//"), "Path must be a same-origin path") + .refine( + (value) => ALLOWED_TRY_PATH_PREFIXES.some((prefix) => value.startsWith(prefix)), + "Path must target an OmniRoute API endpoint" + ), headers: z.record(z.string(), z.string()).optional().default({}), body: z.any().optional(), }); +function getRequestOrigin(request: NextRequest) { + return request.nextUrl?.origin || new URL(request.url).origin; +} + +function buildForwardHeaders(headers: Record) { + const forwardHeaders: Record = {}; + + for (const [key, value] of Object.entries(headers)) { + const normalizedKey = key.trim().toLowerCase(); + if (!normalizedKey || BLOCKED_FORWARD_HEADERS.has(normalizedKey)) continue; + forwardHeaders[key] = value; + } + + return forwardHeaders; +} + export async function POST(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const rawBody = await request.json(); const validation = validateBody(tryRequestSchema, rawBody); @@ -27,19 +73,16 @@ export async function POST(request: NextRequest) { const { method, path, headers, body: reqBody } = validation.data; - // Build the target URL using the incoming request's origin - const origin = request.headers.get("x-forwarded-proto") - ? `${request.headers.get("x-forwarded-proto")}://${request.headers.get("host")}` - : `http://${request.headers.get("host") || "localhost:20128"}`; - - const targetUrl = `${origin}${path}`; + const origin = getRequestOrigin(request); + const targetUrl = new URL(path, origin); + if (targetUrl.origin !== origin) { + return NextResponse.json({ error: "Path must be same-origin" }, { status: 400 }); + } const start = performance.now(); // Forward cookies/auth from the original request - const forwardHeaders: Record = { - ...(headers as Record), - }; + const forwardHeaders = buildForwardHeaders(headers as Record); // Forward auth from the dashboard session const cookie = request.headers.get("cookie"); diff --git a/src/app/api/providers/[id]/codex-auth/apply-local/route.ts b/src/app/api/providers/[id]/codex-auth/apply-local/route.ts index 71b170f52f..34ca3f4a25 100644 --- a/src/app/api/providers/[id]/codex-auth/apply-local/route.ts +++ b/src/app/api/providers/[id]/codex-auth/apply-local/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime"; import { CodexAuthFileError, writeCodexAuthFileToLocalCli } from "@/lib/oauth/utils/codexAuthFile"; @@ -17,7 +18,10 @@ function toErrorResponse(error: unknown) { return NextResponse.json({ error: message }, { status: 500 }); } -export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) { +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const writeGuard = ensureCliConfigWriteAllowed(); if (writeGuard) { diff --git a/src/app/api/providers/[id]/codex-auth/export/route.ts b/src/app/api/providers/[id]/codex-auth/export/route.ts index f435abc2f2..46d9437634 100644 --- a/src/app/api/providers/[id]/codex-auth/export/route.ts +++ b/src/app/api/providers/[id]/codex-auth/export/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { buildCodexAuthFile, CodexAuthFileError } from "@/lib/oauth/utils/codexAuthFile"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; function toErrorResponse(error: unknown) { if (error instanceof CodexAuthFileError) { @@ -17,6 +18,9 @@ function toErrorResponse(error: unknown) { } export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(_request); + if (authError) return authError; + try { const { id } = await params; const built = await buildCodexAuthFile(id); diff --git a/src/app/api/restart/route.ts b/src/app/api/restart/route.ts index 74f1d3f954..26d90f7600 100644 --- a/src/app/api/restart/route.ts +++ b/src/app/api/restart/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; -export async function POST() { // Graceful restart: SIGTERM flows through the shutdown handler before the process manager restarts setTimeout(() => { process.kill(process.pid, "SIGTERM"); diff --git a/src/app/api/settings/auto-disable-accounts/route.ts b/src/app/api/settings/auto-disable-accounts/route.ts index e6f2e3a9f4..3b02364d8e 100644 --- a/src/app/api/settings/auto-disable-accounts/route.ts +++ b/src/app/api/settings/auto-disable-accounts/route.ts @@ -2,8 +2,11 @@ import { NextResponse } from "next/server"; import { getSettings, updateSettings } from "@/lib/localDb"; import { updateAutoDisableAccountsSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -export async function GET() { +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; try { const settings = await getSettings(); return NextResponse.json({ @@ -20,6 +23,8 @@ export async function GET() { } export async function PUT(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; let rawBody: unknown; try { rawBody = await request.json(); diff --git a/src/app/api/settings/background-degradation/route.ts b/src/app/api/settings/background-degradation/route.ts index cb19443fd6..fc7f526add 100644 --- a/src/app/api/settings/background-degradation/route.ts +++ b/src/app/api/settings/background-degradation/route.ts @@ -7,12 +7,15 @@ import { import { updateSettings } from "@/lib/db/settings"; import { jsonObjectSchema, resetStatsActionSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; /** * GET /api/settings/background-degradation * Returns the current background degradation configuration. */ -export async function GET() { +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; try { return NextResponse.json(getBackgroundDegradationConfig()); } catch (error) { @@ -26,7 +29,9 @@ export async function GET() { * Update the background degradation configuration. * Body: { enabled?: boolean, degradationMap?: {...}, detectionPatterns?: [...] } */ -export async function PUT(request) { +export async function PUT(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; let rawBody; try { rawBody = await request.json(); @@ -67,7 +72,9 @@ export async function PUT(request) { * Reset stats counters. * Body: { action: "reset-stats" } */ -export async function POST(request) { +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/settings/cache-metrics/route.ts b/src/app/api/settings/cache-metrics/route.ts index 5154802455..236093dc6f 100644 --- a/src/app/api/settings/cache-metrics/route.ts +++ b/src/app/api/settings/cache-metrics/route.ts @@ -1,7 +1,10 @@ import { NextResponse } from "next/server"; import { getCacheMetrics, resetCacheMetrics } from "@/lib/db/settings"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -export async function GET() { +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; try { const metrics = await getCacheMetrics(); return NextResponse.json(metrics); @@ -11,7 +14,9 @@ export async function GET() { } } -export async function DELETE() { +export async function DELETE(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; try { const metrics = await resetCacheMetrics(); return NextResponse.json(metrics); diff --git a/src/app/api/settings/combo-defaults/route.ts b/src/app/api/settings/combo-defaults/route.ts index 49c01285b4..5909d26d04 100644 --- a/src/app/api/settings/combo-defaults/route.ts +++ b/src/app/api/settings/combo-defaults/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { getSettings, updateSettings } from "@/lib/localDb"; import { updateComboDefaultsSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; const LEGACY_COMBO_RESILIENCE_KEYS = new Set([ "timeoutMs", @@ -33,7 +34,9 @@ function sanitizeProviderOverrides(overrides?: Record | null) { * GET /api/settings/combo-defaults * Returns the current combo global defaults and provider overrides */ -export async function GET() { +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; try { const settings: any = await getSettings(); const comboDefaults = sanitizeComboRuntimeConfig(settings.comboDefaults); @@ -65,7 +68,9 @@ export async function GET() { * Update combo global defaults and/or provider overrides * Body: { comboDefaults?: {...}, providerOverrides?: {...} } */ -export async function PATCH(request) { +export async function PATCH(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/settings/ip-filter/route.ts b/src/app/api/settings/ip-filter/route.ts index b955a544bb..0eda32bc88 100644 --- a/src/app/api/settings/ip-filter/route.ts +++ b/src/app/api/settings/ip-filter/route.ts @@ -11,8 +11,11 @@ import { } from "@omniroute/open-sse/services/ipFilter.ts"; import { updateIpFilterSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -export async function GET() { +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; try { return NextResponse.json(getIPFilterConfig()); } catch (error) { @@ -21,7 +24,9 @@ export async function GET() { } } -export async function PUT(request) { +export async function PUT(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/settings/model-aliases/route.ts b/src/app/api/settings/model-aliases/route.ts index 5530dbb924..4538276c85 100644 --- a/src/app/api/settings/model-aliases/route.ts +++ b/src/app/api/settings/model-aliases/route.ts @@ -14,12 +14,15 @@ import { updateModelAliasesSchema, } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; /** * GET /api/settings/model-aliases * Returns the full alias map, separated into built-in and custom. */ -export async function GET() { +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; try { return NextResponse.json({ builtIn: getBuiltInAliases(), @@ -37,7 +40,9 @@ export async function GET() { * Update the custom aliases map. * Body: { aliases: { "old-model": "new-model", ... } } */ -export async function PUT(request) { +export async function PUT(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; let rawBody; try { rawBody = await request.json(); @@ -73,7 +78,9 @@ export async function PUT(request) { * Add a single custom alias. * Body: { from: "old-model", to: "new-model" } */ -export async function POST(request) { +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; let rawBody; try { rawBody = await request.json(); @@ -109,7 +116,9 @@ export async function POST(request) { * Remove a custom alias. * Body: { from: "old-model" } */ -export async function DELETE(request) { +export async function DELETE(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/settings/proxies/assignments/route.ts b/src/app/api/settings/proxies/assignments/route.ts index bccb033a70..39e3d188ba 100644 --- a/src/app/api/settings/proxies/assignments/route.ts +++ b/src/app/api/settings/proxies/assignments/route.ts @@ -3,8 +3,11 @@ import { proxyAssignmentSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; try { const { searchParams } = new URL(request.url); const proxyId = searchParams.get("proxyId"); @@ -31,6 +34,8 @@ export async function GET(request: Request) { } export async function PUT(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; let rawBody: unknown; try { rawBody = await request.json(); diff --git a/src/app/api/settings/proxies/bulk-assign/route.ts b/src/app/api/settings/proxies/bulk-assign/route.ts index 0d69458c56..bcd104bacf 100644 --- a/src/app/api/settings/proxies/bulk-assign/route.ts +++ b/src/app/api/settings/proxies/bulk-assign/route.ts @@ -3,8 +3,12 @@ import { bulkProxyAssignmentSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; export async function PUT(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + let rawBody: unknown; try { rawBody = await request.json(); diff --git a/src/app/api/settings/proxies/health/route.ts b/src/app/api/settings/proxies/health/route.ts index 3ef0ff02f7..e12c10a8b3 100644 --- a/src/app/api/settings/proxies/health/route.ts +++ b/src/app/api/settings/proxies/health/route.ts @@ -1,7 +1,11 @@ import { getProxyHealthStats } from "@/lib/localDb"; import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const { searchParams } = new URL(request.url); const hours = Number(searchParams.get("hours") || 24); diff --git a/src/app/api/settings/proxies/migrate/route.ts b/src/app/api/settings/proxies/migrate/route.ts index a8def9dc42..c90a194c45 100644 --- a/src/app/api/settings/proxies/migrate/route.ts +++ b/src/app/api/settings/proxies/migrate/route.ts @@ -2,12 +2,16 @@ import { migrateLegacyProxyConfigToRegistry } from "@/lib/localDb"; import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { z } from "zod"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; const migrateLegacyProxySchema = z.object({ force: z.boolean().optional(), }); export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + let rawBody: unknown; try { diff --git a/src/app/api/settings/proxies/route.ts b/src/app/api/settings/proxies/route.ts index 7540432728..c92980fa1d 100644 --- a/src/app/api/settings/proxies/route.ts +++ b/src/app/api/settings/proxies/route.ts @@ -9,8 +9,11 @@ import { import { createProxyRegistrySchema, updateProxyRegistrySchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; try { const { searchParams } = new URL(request.url); const id = searchParams.get("id"); @@ -37,6 +40,8 @@ export async function GET(request: Request) { } export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; let rawBody: unknown; try { rawBody = await request.json(); @@ -67,6 +72,8 @@ export async function POST(request: Request) { } export async function PATCH(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; let rawBody: unknown; try { rawBody = await request.json(); @@ -102,6 +109,8 @@ export async function PATCH(request: Request) { } export async function DELETE(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; try { const { searchParams } = new URL(request.url); const id = searchParams.get("id"); diff --git a/src/app/api/settings/proxy/route.ts b/src/app/api/settings/proxy/route.ts index aa5fdf4417..59b64aec69 100755 --- a/src/app/api/settings/proxy/route.ts +++ b/src/app/api/settings/proxy/route.ts @@ -16,6 +16,7 @@ import { type ApiErrorType, } from "@/lib/api/errorResponse"; import type { z } from "zod"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; const BASE_SUPPORTED_PROXY_TYPES = new Set(["http", "https"]); type UpdateProxyConfigInput = z.infer; @@ -120,6 +121,9 @@ function normalizeProxyPayload(body: UpdateProxyConfigInput): UpdateProxyConfigI * Or: ?resolve=connectionId to resolve effective proxy */ export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const { searchParams } = new URL(request.url); const level = searchParams.get("level"); @@ -174,6 +178,9 @@ export async function GET(request: Request) { * Body: { level, id?, proxy } or legacy { global?, providers? } */ export async function PUT(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + let rawBody: unknown; try { rawBody = await request.json(); @@ -214,6 +221,9 @@ export async function PUT(request: Request) { * Query: ?level=provider&id=xxx */ export async function DELETE(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const { searchParams } = new URL(request.url); const level = searchParams.get("level"); diff --git a/src/app/api/settings/proxy/test/route.ts b/src/app/api/settings/proxy/test/route.ts index cace16576c..b487ce475b 100644 --- a/src/app/api/settings/proxy/test/route.ts +++ b/src/app/api/settings/proxy/test/route.ts @@ -9,6 +9,7 @@ import { testProxySchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; import { getProxyById } from "@/lib/localDb"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; const BASE_SUPPORTED_PROXY_TYPES = new Set(["http", "https"]); @@ -36,6 +37,9 @@ function supportedTypesMessage() { * Returns: { success, publicIp?, latencyMs?, error? } */ export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + let rawBody: unknown; try { rawBody = await request.json(); diff --git a/src/app/api/settings/system-prompt/route.ts b/src/app/api/settings/system-prompt/route.ts index 31860ee09c..63f1209ac2 100644 --- a/src/app/api/settings/system-prompt/route.ts +++ b/src/app/api/settings/system-prompt/route.ts @@ -6,8 +6,11 @@ import { import { updateSettings } from "@/lib/localDb"; import { updateSystemPromptSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -export async function GET() { +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; try { return NextResponse.json(getSystemPromptConfig()); } catch (error) { @@ -16,7 +19,9 @@ export async function GET() { } } -export async function PUT(request) { +export async function PUT(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/settings/task-routing/route.ts b/src/app/api/settings/task-routing/route.ts index adefe3dab5..a979ff040b 100644 --- a/src/app/api/settings/task-routing/route.ts +++ b/src/app/api/settings/task-routing/route.ts @@ -8,12 +8,15 @@ import { import { updateSettings } from "@/lib/db/settings"; import { taskRoutingActionSchema, updateTaskRoutingSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; /** * GET /api/settings/task-routing * Returns the current task-aware routing configuration. */ -export async function GET() { +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; try { return NextResponse.json({ ...getTaskRoutingConfig(), @@ -31,6 +34,8 @@ export async function GET() { * Body: { enabled?: boolean, taskModelMap?: { coding?: "...", ... }, detectionEnabled?: boolean } */ export async function PUT(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; let rawBody: unknown; try { rawBody = await request.json(); @@ -73,6 +78,8 @@ export async function PUT(request: Request) { * For "detect": pass { action: "detect", body: } to test detection */ export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; let rawBody: unknown; try { rawBody = await request.json(); diff --git a/src/app/api/settings/thinking-budget/route.ts b/src/app/api/settings/thinking-budget/route.ts index bbeee426b7..5f2d5932bc 100644 --- a/src/app/api/settings/thinking-budget/route.ts +++ b/src/app/api/settings/thinking-budget/route.ts @@ -7,8 +7,11 @@ import { } from "@omniroute/open-sse/services/thinkingBudget.ts"; import { updateThinkingBudgetSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -export async function GET() { +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; try { const config = getThinkingBudgetConfig(); return NextResponse.json(config); @@ -18,7 +21,9 @@ export async function GET() { } } -export async function PUT(request) { +export async function PUT(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; let rawBody; try { rawBody = await request.json(); diff --git a/src/app/api/shutdown/route.ts b/src/app/api/shutdown/route.ts index eddf43c4a1..8e5887da18 100644 --- a/src/app/api/shutdown/route.ts +++ b/src/app/api/shutdown/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; -export async function POST() { const response = NextResponse.json({ success: true, message: "Shutting down..." }); setTimeout(() => { diff --git a/src/app/api/skills/[id]/route.ts b/src/app/api/skills/[id]/route.ts index 80d5fc9b49..50d5045c5d 100644 --- a/src/app/api/skills/[id]/route.ts +++ b/src/app/api/skills/[id]/route.ts @@ -3,6 +3,7 @@ import { getDbInstance } from "@/lib/db/core"; import { skillRegistry } from "@/lib/skills/registry"; import { z } from "zod"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; const updateSkillSchema = z.object({ enabled: z.boolean().optional(), @@ -10,6 +11,9 @@ const updateSkillSchema = z.object({ }); export async function DELETE(_request: Request, props: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(_request); + if (authError) return authError; + try { const { id } = await props.params; const deleted = await skillRegistry.unregisterById(id); @@ -24,6 +28,9 @@ export async function DELETE(_request: Request, props: { params: Promise<{ id: s } export async function PUT(request: Request, props: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const { id } = await props.params; const rawBody = await request.json(); diff --git a/src/app/api/skills/install/route.ts b/src/app/api/skills/install/route.ts index d077f44db0..256f262040 100644 --- a/src/app/api/skills/install/route.ts +++ b/src/app/api/skills/install/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { skillRegistry } from "@/lib/skills/registry"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; const installManifestSchema = z.object({ name: z.string().min(1).max(100), @@ -19,6 +20,9 @@ const installManifestSchema = z.object({ }); export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const rawBody = await request.json(); const validation = validateBody(installManifestSchema, rawBody); diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts index 7e4c10fa98..18fd67ee29 100644 --- a/src/app/api/skills/route.ts +++ b/src/app/api/skills/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { skillRegistry } from "@/lib/skills/registry"; import { parsePaginationParams, buildPaginatedResponse } from "@/shared/types/pagination"; import { getSkillsProviderSetting } from "@/lib/skills/providerSettings"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; const POPULAR_BY_PROVIDER = { skillsmp: ["web-search", "file-reader", "sql-assistant", "devops-helper", "docs-assistant"], @@ -9,6 +10,9 @@ const POPULAR_BY_PROVIDER = { } as const; export async function GET(request?: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { await skillRegistry.loadFromDatabase(); const provider = await getSkillsProviderSetting(); diff --git a/src/app/api/webhooks/[id]/route.ts b/src/app/api/webhooks/[id]/route.ts index 4c015cf8d0..f15cc98d04 100644 --- a/src/app/api/webhooks/[id]/route.ts +++ b/src/app/api/webhooks/[id]/route.ts @@ -9,6 +9,7 @@ import { z } from "zod"; import { NextResponse } from "next/server"; import { getWebhook, updateWebhookRecord, deleteWebhook } from "@/lib/localDb"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; const updateWebhookSchema = z .object({ @@ -21,6 +22,9 @@ const updateWebhookSchema = z .passthrough(); export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(_); + if (authError) return authError; + try { const { id } = await params; const webhook = getWebhook(id); @@ -34,6 +38,9 @@ export async function GET(_: Request, { params }: { params: Promise<{ id: string } export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const { id } = await params; const rawBody = await request.json(); @@ -53,6 +60,9 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: } export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(_); + if (authError) return authError; + try { const { id } = await params; const deleted = deleteWebhook(id); diff --git a/src/app/api/webhooks/[id]/test/route.ts b/src/app/api/webhooks/[id]/test/route.ts index e583ca20ca..11686cdb94 100644 --- a/src/app/api/webhooks/[id]/test/route.ts +++ b/src/app/api/webhooks/[id]/test/route.ts @@ -6,8 +6,12 @@ import { NextResponse } from "next/server"; import { getWebhook, recordWebhookDelivery } from "@/lib/localDb"; import { deliverWebhook } from "@/lib/webhookDispatcher"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; export async function POST(_: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(_); + if (authError) return authError; + try { const { id } = await params; const webhook = getWebhook(id); diff --git a/src/app/api/webhooks/route.ts b/src/app/api/webhooks/route.ts index 7e4dccb190..1089ca0ebc 100644 --- a/src/app/api/webhooks/route.ts +++ b/src/app/api/webhooks/route.ts @@ -8,6 +8,7 @@ import { z } from "zod"; import { NextResponse } from "next/server"; import { getWebhooks, createWebhook } from "@/lib/localDb"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; const createWebhookSchema = z.object({ url: z.string().url("Invalid URL format").max(2000), @@ -16,7 +17,10 @@ const createWebhookSchema = z.object({ description: z.string().max(1000).optional().default(""), }); -export async function GET() { +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const webhooks = getWebhooks(); // Mask secrets in listing @@ -34,6 +38,9 @@ export async function GET() { } export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { const rawBody = await request.json(); const validation = validateBody(createWebhookSchema, rawBody); diff --git a/tests/unit/management-auth-hardening.test.ts b/tests/unit/management-auth-hardening.test.ts new file mode 100644 index 0000000000..7a1f4a23e1 --- /dev/null +++ b/tests/unit/management-auth-hardening.test.ts @@ -0,0 +1,18 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; + +test("Codex apply-local auth route requires management authentication before local writes", () => { + const content = fs.readFileSync( + "src/app/api/providers/[id]/codex-auth/apply-local/route.ts", + "utf8" + ); + + assert.ok(content.includes('from "@/lib/api/requireManagementAuth"')); + assert.ok(content.includes("const authError = await requireManagementAuth(request);")); + assert.ok(content.includes("if (authError) return authError;")); + assert.ok( + content.indexOf("requireManagementAuth(request)") < + content.indexOf("ensureCliConfigWriteAllowed()") + ); +}); diff --git a/tests/unit/openapi-try-route.test.ts b/tests/unit/openapi-try-route.test.ts new file mode 100644 index 0000000000..4ab2edab5a --- /dev/null +++ b/tests/unit/openapi-try-route.test.ts @@ -0,0 +1,161 @@ +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"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-openapi-try-route-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; +const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET; + +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.INITIAL_PASSWORD = "openapi-try-password"; +process.env.JWT_SECRET = "openapi-try-jwt-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const route = await import("../../src/app/api/openapi/try/route.ts"); + +const originalFetch = globalThis.fetch; + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function createAuthCookie() { + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const token = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("30d") + .sign(secret); + return `auth_token=${token}`; +} + +function makeRequest(body: unknown, cookie?: string) { + return new Request("http://localhost/api/openapi/try", { + method: "POST", + headers: { + "content-type": "application/json", + ...(cookie ? { cookie } : {}), + }, + body: JSON.stringify(body), + }); +} + +test.beforeEach(async () => { + await resetStorage(); + globalThis.fetch = originalFetch; +}); + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test.after(() => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + + if (ORIGINAL_DATA_DIR === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } + if (ORIGINAL_INITIAL_PASSWORD === undefined) { + delete process.env.INITIAL_PASSWORD; + } else { + process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; + } + if (ORIGINAL_JWT_SECRET === undefined) { + delete process.env.JWT_SECRET; + } else { + process.env.JWT_SECRET = ORIGINAL_JWT_SECRET; + } +}); + +test("openapi try route requires management authentication before proxying", async () => { + let fetchCalled = false; + globalThis.fetch = async () => { + fetchCalled = true; + return new Response("unexpected"); + }; + + const response = await route.POST( + makeRequest({ + method: "GET", + path: "/api/monitoring/health", + }) as any + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 401); + assert.equal(body.error.message, "Authentication required"); + assert.equal(fetchCalled, false); +}); + +test("openapi try route rejects protocol-relative targets after authentication", async () => { + let fetchCalled = false; + globalThis.fetch = async () => { + fetchCalled = true; + return new Response("unexpected"); + }; + + const response = await route.POST( + makeRequest( + { + method: "GET", + path: "//evil.example/api", + }, + await createAuthCookie() + ) as any + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 400); + assert.equal(body.error.message, "Invalid request"); + assert.equal(fetchCalled, false); +}); + +test("openapi try route strips hop-by-hop headers and proxies same-origin API paths", async () => { + const cookie = await createAuthCookie(); + let fetchUrl = ""; + let fetchInit: RequestInit | undefined; + globalThis.fetch = async (url, init) => { + fetchUrl = String(url); + fetchInit = init; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const response = await route.POST( + makeRequest( + { + method: "POST", + path: "/api/combos/test", + headers: { + Authorization: "Bearer test-key", + Host: "evil.example", + "X-Forwarded-Proto": "https", + }, + body: { comboName: "smoke" }, + }, + cookie + ) as any + ); + const body = (await response.json()) as any; + const forwardedHeaders = fetchInit?.headers as Record; + + assert.equal(response.status, 200); + assert.equal(body.status, 200); + assert.equal(fetchUrl, "http://localhost/api/combos/test"); + assert.equal(fetchInit?.method, "POST"); + assert.equal(forwardedHeaders.Authorization, "Bearer test-key"); + assert.equal(forwardedHeaders.Host, undefined); + assert.equal(forwardedHeaders["X-Forwarded-Proto"], undefined); + assert.equal(forwardedHeaders.Cookie, cookie); +});