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:
foxy1402 2026-10-13 18:16:08 +07:00 • committed by GitHub
parent 59a474c59a
commit 146459b116
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 148 additions and 4 deletions

View 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))

View file

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

View file

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

View file

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

View file

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

View file

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

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