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.
This commit is contained in:
diegosouzapw
2026-04-25 16:45:59 -03:00
parent ce9dc8e851
commit f9e799d089
35 changed files with 438 additions and 35 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, string>) {
const forwardHeaders: Record<string, string> = {};
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<string, string> = {
...(headers as Record<string, string>),
};
const forwardHeaders = buildForwardHeaders(headers as Record<string, string>);
// Forward auth from the dashboard session
const cookie = request.headers.get("cookie");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, any> | 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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: <request-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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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