Files
OmniRoute/tests/unit/authz/proxy-contract.test.ts
Xiangzhe a2d5ef50f4 fix(security): match client-API aliases case-insensitively in the authz matcher
Next compiles the proxy matcher from `regexp.source` only, dropping
path-to-regexp's case-insensitive flag, so `/v1/:path*` never matched `/V1/...`
while the rewrite layer (flag kept) still routed it to the handler — an
unauthenticated inference bypass via uppercase / mixed-case paths (/V1, /V1BETA,
/CHAT, /RESPONSES, /CODEX, /MODELS). Expressing the casing inside a
path-to-regexp custom group (`([vV]1)`) survives the flag-drop. classify.ts
normalizes the control segment case so uppercase aliases resolve to CLIENT_API
(honoring REQUIRE_API_KEY) instead of the management fallback.

Reported by @Evgeny-SPB via GHSA-jvqc-mp9f-q936.
2026-08-21 13:14:53 -03:00

87 lines
3.7 KiB
TypeScript

/**
* Next.js 16 Proxy File Contract — Lockdown Test
*
* Next.js 16 deprecated `middleware.ts` in favour of `proxy.ts` (commit
* 3fb72b973 renamed our copy to match the new convention). The framework
* only invokes this file when ALL of the following hold:
*
* 1. The file lives at `src/proxy.ts` (since `src/app` is the app dir).
* 2. It exports a function named exactly `proxy` (or default).
* 3. The function delegates to `runAuthzPipeline` with `enforce: true`.
* 4. The `config.matcher` covers every prefix routes are mounted under,
* so unauthenticated requests cannot slip past the centralized
* authorization tiers (PUBLIC / CLIENT_API / MANAGEMENT).
*
* Without ANY of these guarantees the pipeline silently becomes dead code
* and every `/api/*` route falls back to per-route self-enforcement, which
* is the failure mode that led to the v3.8.4 hardening pass. Lock the
* contract down here so a future rename / refactor cannot regress it
* unnoticed.
*
* @see docs/security/ROUTE_GUARD_TIERS.md
* @see https://nextjs.org/docs/app/api-reference/file-conventions/proxy
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
test("Next.js 16 proxy file exists at src/proxy.ts (not src/middleware.ts)", () => {
assert.ok(fs.existsSync("src/proxy.ts"), "src/proxy.ts must exist (Next.js 16 file convention)");
assert.ok(
!fs.existsSync("src/middleware.ts"),
"src/middleware.ts must NOT exist — Next.js 16 deprecated middleware.ts, the active file is src/proxy.ts"
);
});
test("proxy.ts exports a function named 'proxy' (Next.js 16 requires this exact name)", () => {
const content = fs.readFileSync("src/proxy.ts", "utf8");
assert.match(
content,
/export\s+async\s+function\s+proxy\s*\(/,
"must export `async function proxy(...)` — Next.js 16 only invokes this exact name"
);
});
test("proxy.ts delegates to runAuthzPipeline with enforce: true", () => {
const content = fs.readFileSync("src/proxy.ts", "utf8");
assert.match(
content,
/runAuthzPipeline\([^)]*\{\s*enforce:\s*true\s*\}\s*\)/,
"must call runAuthzPipeline with { enforce: true } — otherwise the pipeline runs in observe-only mode and never blocks"
);
});
test("proxy.ts config.matcher covers every /api/* route plus dashboard and v1 aliases", () => {
const content = fs.readFileSync("src/proxy.ts", "utf8");
// Required prefixes — drop one and the corresponding routes go unguarded.
// The client-API aliases use a case-insensitive path-to-regexp group
// (`([vV]1)`) so `/V1/...` reaches the pipeline too — see
// GHSA-jvqc-mp9f-q936 and tests/unit/authz/proxy-matcher-case.test.ts for the
// semantic (compiled-matcher) coverage assertions.
const requiredMatchers = [
'"/api/:path*"',
'"/dashboard/:path*"',
'"/:v1seg([vV]1)/:path*"',
'"/:v1betaseg([vV]1[bB][eE][tT][aA])/:path*"',
'"/:chatseg([cC][hH][aA][tT])/:path*"',
'"/:respseg([rR][eE][sS][pP][oO][nN][sS][eE][sS])/:path*"',
'"/:codexseg([cC][oO][dD][eE][xX])/:path*"',
'"/:modelsseg([mM][oO][dD][eE][lL][sS])"',
];
for (const matcher of requiredMatchers) {
assert.ok(
content.includes(matcher),
`proxy.ts config.matcher must include ${matcher} — otherwise routes under that prefix bypass the authz pipeline`
);
}
});
test("proxy.ts does not declare runtime: 'edge' (Next.js 16 proxy is Node-only)", () => {
const content = fs.readFileSync("src/proxy.ts", "utf8");
assert.ok(
!/runtime:\s*['"]edge['"]/.test(content),
"proxy.ts MUST NOT set runtime: 'edge' — Next.js 16 only supports nodejs in proxy.ts. The pipeline depends on Node-only modules (jose, better-sqlite3) and would crash at request time on the edge runtime."
);
});