mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
18
tests/unit/management-auth-hardening.test.ts
Normal file
18
tests/unit/management-auth-hardening.test.ts
Normal 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()")
|
||||
);
|
||||
});
|
||||
161
tests/unit/openapi-try-route.test.ts
Normal file
161
tests/unit/openapi-try-route.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user