mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-10-19 13:30:37 +00:00
fix(security): close auth bypass via uppercase path variants (#16403)
Thanks @foxy1402 — the chat, completions, messages, and responses handlers now call `enforceClientApiRouteAuth` themselves, so an uppercase `/V1/` path that misses the proxy matcher cannot dispatch without a key. Covered by `tests/unit/authz/handler-auth-bypass.test.ts`.
Boarded on release/v3.8.52 at b0d53251b3 (after #15840). Focused unit tests: 43 passed, 0 failed. Changelog integrity passed. File-size: the inherited executeTargetAttempt.ts overage (1288 > 1284) remains. #16427 was ejected from this wave because it grows open-sse/executors/codex.ts from 1526 to 1608, over the frozen ceiling. These seven pull requests do not touch that file.
⚠️ base-red inherited: #15306
This commit is contained in:
parent
59a474c59a
commit
146459b116
7 changed files with 148 additions and 4 deletions
1
changelog.d/fixes/16403-v1-uppercase-auth-bypass.md
Normal file
1
changelog.d/fixes/16403-v1-uppercase-auth-bypass.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
- **fix(security):** close authentication bypass via uppercase path variants. The authz proxy matcher's custom regex groups (e.g. `/:v1seg([vV]1)/:path*`) do not reliably match uppercase aliases in production, so requests like `POST /V1/chat/completions` skipped the proxy and reached the dispatch layer with no API key (the route handlers performed no authentication of their own). The critical inference handlers (`chat/completions`, `completions`, `messages`, `responses`, `responses/[...path]`) now enforce `enforceClientApiRouteAuth` directly as defense-in-depth. ([#16403](https://github.com/diegosouzapw/OmniRoute/pull/16403))
|
||||
|
|
@ -35,6 +35,7 @@ import {
|
|||
} from "@/shared/utils/compressionHeaderEcho";
|
||||
import { resolveModelAliasWithSeedFallbackOnBody } from "@/lib/modelAliasResolver";
|
||||
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
|
||||
import { enforceClientApiRouteAuth } from "@/shared/utils/clientApiRouteAuth";
|
||||
import {
|
||||
assertRuntimeModelProviderAvailable,
|
||||
isRuntimeProviderRetirementError,
|
||||
|
|
@ -100,6 +101,13 @@ export async function OPTIONS() {
|
|||
export async function POST(request) {
|
||||
await ensureInitialized();
|
||||
|
||||
// Defense-in-depth: the authz proxy (src/proxy.ts) is the primary gate, but
|
||||
// its matcher can miss path variants (e.g. uppercase /V1/ aliases), so the
|
||||
// handler enforces authentication independently. Without this, a proxy
|
||||
// bypass means unauthenticated upstream dispatch.
|
||||
const authRejection = await enforceClientApiRouteAuth(request);
|
||||
if (authRejection) return authRejection;
|
||||
|
||||
// Content-Type guard (#6414) — reject non-JSON POST bodies with 415 per RFC 7231.
|
||||
// OpenAI/Anthropic reject `text/plain` or missing Content-Type at the edge; matching
|
||||
// that behavior prevents a text/plain body from silently reaching provider lookup.
|
||||
|
|
@ -311,10 +319,8 @@ export async function POST(request) {
|
|||
|
||||
if (wantsStreaming) {
|
||||
const reqId = callerCorrelationId ?? generateRequestId();
|
||||
const {
|
||||
signal: routeDeadlineSignal,
|
||||
deadlineController: routeDeadlineController,
|
||||
} = createStreamDeadlineSignal(request.signal);
|
||||
const { signal: routeDeadlineSignal, deadlineController: routeDeadlineController } =
|
||||
createStreamDeadlineSignal(request.signal);
|
||||
// Wrap the real handler response, not the synthetic early-keepalive response. If the
|
||||
// client cancels while handleChat is still pending, earlyStreamKeepalive will cancel the
|
||||
// eventual handler body; only that confirmed cleanup releases heavyweight capacity.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
withCompressionHeaderEcho,
|
||||
} from "@/shared/utils/compressionHeaderEcho";
|
||||
import { withChatAdmission } from "@/shared/middleware/withChatAdmission";
|
||||
import { enforceClientApiRouteAuth } from "@/shared/utils/clientApiRouteAuth";
|
||||
|
||||
let initPromise = null;
|
||||
// `logger: null` — the guardrail registry re-evaluates this request inside
|
||||
|
|
@ -45,6 +46,12 @@ export async function OPTIONS() {
|
|||
* @see https://platform.openai.com/docs/api-reference/completions
|
||||
*/
|
||||
async function postHandler(request: Request) {
|
||||
// Defense-in-depth: the authz proxy (src/proxy.ts) is the primary gate, but
|
||||
// its matcher can miss path variants (e.g. uppercase /V1/ aliases), so the
|
||||
// handler enforces authentication independently.
|
||||
const authRejection = await enforceClientApiRouteAuth(request);
|
||||
if (authRejection) return authRejection;
|
||||
|
||||
await ensureInitialized();
|
||||
|
||||
// #6422 — capture the compression request header once so we can echo it back
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
|
|||
import { withInjectionGuard } from "@/middleware/promptInjectionGuard";
|
||||
import { withChatAdmission } from "@/shared/middleware/withChatAdmission";
|
||||
import { requireJsonContentType } from "@/shared/middleware/requireJsonContentType";
|
||||
import { enforceClientApiRouteAuth } from "@/shared/utils/clientApiRouteAuth";
|
||||
import {
|
||||
withEarlyStreamKeepalive,
|
||||
ANTHROPIC_PING_FRAME,
|
||||
|
|
@ -45,6 +46,12 @@ export async function OPTIONS() {
|
|||
* parsed at most once per request.
|
||||
*/
|
||||
async function postHandler(request: any, context: any, preParsedBody: any = null) {
|
||||
// Defense-in-depth: the authz proxy (src/proxy.ts) is the primary gate, but
|
||||
// its matcher can miss path variants (e.g. uppercase /V1/ aliases), so the
|
||||
// handler enforces authentication independently.
|
||||
const authRejection = await enforceClientApiRouteAuth(request);
|
||||
if (authRejection) return authRejection;
|
||||
|
||||
// Reject non-JSON Content-Type with 415 before touching the body — mirrors OpenAI's
|
||||
// reference API and matches /v1/chat/completions (#6414).
|
||||
const ctRejection = requireJsonContentType(request);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { handleChat } from "@/sse/handlers/chat";
|
||||
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
|
||||
import { withChatAdmission } from "@/shared/middleware/withChatAdmission";
|
||||
import { enforceClientApiRouteAuth } from "@/shared/utils/clientApiRouteAuth";
|
||||
|
||||
let initialized = false;
|
||||
|
||||
|
|
@ -27,6 +28,12 @@ export async function OPTIONS() {
|
|||
* arbitrary Responses suffixes all the way to the upstream provider.
|
||||
*/
|
||||
async function postHandler(request) {
|
||||
// Defense-in-depth: the authz proxy (src/proxy.ts) is the primary gate, but
|
||||
// its matcher can miss path variants (e.g. uppercase /V1/ aliases), so the
|
||||
// handler enforces authentication independently.
|
||||
const authRejection = await enforceClientApiRouteAuth(request);
|
||||
if (authRejection) return authRejection;
|
||||
|
||||
await ensureInitialized();
|
||||
return await handleChat(request);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { createInjectionGuard } from "@/middleware/promptInjectionGuard";
|
|||
import { resolveResponsesApiModel } from "@/app/api/internal/codex-responses-ws/modelResolution";
|
||||
import { getModelInfo, getComboForModel } from "@/sse/services/model";
|
||||
import { generateRequestId } from "@/shared/utils/requestId";
|
||||
import { enforceClientApiRouteAuth } from "@/shared/utils/clientApiRouteAuth";
|
||||
import { resolveIncomingCorrelationId } from "@/shared/utils/correlationPreserve.ts";
|
||||
import {
|
||||
admitChatRequest,
|
||||
|
|
@ -101,6 +102,12 @@ export async function withCodexPreferredModel(
|
|||
* Handled by the unified chat handler (openai-responses format auto-detected).
|
||||
*/
|
||||
async function postHandler(request: any) {
|
||||
// Defense-in-depth: the authz proxy (src/proxy.ts) is the primary gate, but
|
||||
// its matcher can miss path variants (e.g. uppercase /V1/ aliases), so the
|
||||
// handler enforces authentication independently.
|
||||
const authRejection = await enforceClientApiRouteAuth(request);
|
||||
if (authRejection) return authRejection;
|
||||
|
||||
// Keep the framework Request untouched until the route has parsed enough state
|
||||
// to know this is a streaming response. Deadline lifecycle belongs to that branch.
|
||||
const sessionId = resolveSessionId(request);
|
||||
|
|
|
|||
109
tests/unit/authz/handler-auth-bypass.test.ts
Normal file
109
tests/unit/authz/handler-auth-bypass.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
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";
|
||||
|
||||
/**
|
||||
* Regression tests for the uppercase-path auth bypass.
|
||||
*
|
||||
* The authz proxy (src/proxy.ts) matcher patterns with custom regex groups
|
||||
* (e.g. `/:v1seg([vV]1)/:path*`) do not reliably match uppercase path variants
|
||||
* in production, so requests like `POST /V1/chat/completions` skip the proxy
|
||||
* and reach the route handler. The handlers must therefore enforce
|
||||
* authentication independently via `enforceClientApiRouteAuth` — these tests
|
||||
* pin that handler-level safety net.
|
||||
*/
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-handler-auth-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-api-key-secret-for-handler-auth-tests-0001";
|
||||
|
||||
const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts");
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const { enforceClientApiRouteAuth } =
|
||||
await import("../../../src/shared/utils/clientApiRouteAuth.ts");
|
||||
|
||||
const ORIGINAL_REQUIRE_API_KEY = process.env.REQUIRE_API_KEY;
|
||||
const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
|
||||
|
||||
function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
delete process.env.JWT_SECRET;
|
||||
process.env.REQUIRE_API_KEY = "true";
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
if (ORIGINAL_REQUIRE_API_KEY === undefined) delete process.env.REQUIRE_API_KEY;
|
||||
else process.env.REQUIRE_API_KEY = ORIGINAL_REQUIRE_API_KEY;
|
||||
if (ORIGINAL_JWT_SECRET === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
|
||||
});
|
||||
|
||||
function routeRequest(pathname: string, headers?: HeadersInit): Request {
|
||||
return new Request(`http://localhost${pathname}`, {
|
||||
method: "POST",
|
||||
headers: new Headers(headers),
|
||||
});
|
||||
}
|
||||
|
||||
async function dashboardCookie(): Promise<string> {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-handler-auth-tests-0002";
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
const token = await new SignJWT({ authenticated: true })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("1h")
|
||||
.sign(secret);
|
||||
return `auth_token=${token}`;
|
||||
}
|
||||
|
||||
test("handler guard rejects keyless inference requests (proxy-bypass safety net)", async () => {
|
||||
for (const p of ["/api/v1/chat/completions", "/api/v1/responses", "/api/v1/messages"]) {
|
||||
const rejection = await enforceClientApiRouteAuth(await routeRequest(p));
|
||||
assert.ok(rejection, `keyless ${p} must be rejected at the handler`);
|
||||
assert.equal(rejection.status, 401);
|
||||
}
|
||||
});
|
||||
|
||||
test("handler guard accepts a valid API key", async () => {
|
||||
const created = await apiKeysDb.createApiKey("handler-test-key", "machine-handler-1");
|
||||
assert.ok(created?.key, "createApiKey must return a key");
|
||||
const rejection = await enforceClientApiRouteAuth(
|
||||
await routeRequest("/api/v1/chat/completions", {
|
||||
authorization: `Bearer ${created.key}`,
|
||||
})
|
||||
);
|
||||
assert.equal(rejection, null);
|
||||
});
|
||||
|
||||
test("handler guard rejects an invalid API key", async () => {
|
||||
const rejection = await enforceClientApiRouteAuth(
|
||||
await routeRequest("/api/v1/chat/completions", {
|
||||
authorization: "Bearer invalid-key-xyz",
|
||||
})
|
||||
);
|
||||
assert.ok(rejection, "invalid key must be rejected");
|
||||
assert.equal(rejection.status, 401);
|
||||
});
|
||||
|
||||
test("handler guard accepts a dashboard session (existing behavior preserved)", async () => {
|
||||
const rejection = await enforceClientApiRouteAuth(
|
||||
await routeRequest("/api/v1/chat/completions", { cookie: await dashboardCookie() })
|
||||
);
|
||||
assert.equal(rejection, null);
|
||||
});
|
||||
|
||||
test("handler guard allows anonymous when REQUIRE_API_KEY is off", async () => {
|
||||
process.env.REQUIRE_API_KEY = "false";
|
||||
const rejection = await enforceClientApiRouteAuth(await routeRequest("/api/v1/chat/completions"));
|
||||
assert.equal(rejection, null);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue