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