chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import * as classifyPublicApi from "../../../src/server/authz/classify.ts";
|
||||
import { classifyRoute } from "../../../src/server/authz/classify.ts";
|
||||
import type { RouteClass } from "../../../src/server/authz/types.ts";
|
||||
|
||||
interface Case {
|
||||
name: string;
|
||||
path: string;
|
||||
method?: string;
|
||||
expectedClass: RouteClass;
|
||||
expectedNormalized?: string;
|
||||
}
|
||||
|
||||
const cases: Case[] = [
|
||||
{ name: "root /", path: "/", expectedClass: "MANAGEMENT", expectedNormalized: "/" },
|
||||
{ name: "dashboard root", path: "/dashboard", expectedClass: "MANAGEMENT" },
|
||||
{ name: "dashboard nested", path: "/dashboard/settings", expectedClass: "MANAGEMENT" },
|
||||
{ name: "dashboard onboarding", path: "/dashboard/onboarding", expectedClass: "PUBLIC" },
|
||||
|
||||
{
|
||||
name: "/api/v1 base",
|
||||
path: "/api/v1",
|
||||
expectedClass: "CLIENT_API",
|
||||
expectedNormalized: "/api/v1",
|
||||
},
|
||||
{
|
||||
name: "/api/v1/chat/completions",
|
||||
path: "/api/v1/chat/completions",
|
||||
expectedClass: "CLIENT_API",
|
||||
},
|
||||
{ name: "/api/v1/responses", path: "/api/v1/responses", expectedClass: "CLIENT_API" },
|
||||
{ name: "/api/v1/models", path: "/api/v1/models", expectedClass: "CLIENT_API" },
|
||||
{ name: "/api/v1/embeddings", path: "/api/v1/embeddings", expectedClass: "CLIENT_API" },
|
||||
{ name: "/api/v1/files", path: "/api/v1/files", expectedClass: "CLIENT_API" },
|
||||
{ name: "/api/v1/batches", path: "/api/v1/batches", expectedClass: "CLIENT_API" },
|
||||
{ name: "/api/v1/ws", path: "/api/v1/ws", expectedClass: "CLIENT_API" },
|
||||
{ name: "/api/mcp/* stays management", path: "/api/mcp/status", expectedClass: "MANAGEMENT" },
|
||||
|
||||
{
|
||||
name: "/v1 alias",
|
||||
path: "/v1",
|
||||
expectedClass: "CLIENT_API",
|
||||
expectedNormalized: "/api/v1",
|
||||
},
|
||||
{
|
||||
name: "/v1/chat/completions alias",
|
||||
path: "/v1/chat/completions",
|
||||
expectedClass: "CLIENT_API",
|
||||
expectedNormalized: "/api/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
name: "/v1beta alias",
|
||||
path: "/v1beta",
|
||||
expectedClass: "CLIENT_API",
|
||||
expectedNormalized: "/api/v1beta",
|
||||
},
|
||||
{
|
||||
name: "/v1beta generateContent alias",
|
||||
path: "/v1beta/models/gemini-pro:generateContent",
|
||||
expectedClass: "CLIENT_API",
|
||||
expectedNormalized: "/api/v1beta/models/gemini-pro:generateContent",
|
||||
},
|
||||
{
|
||||
name: "/api/v1beta generateContent",
|
||||
path: "/api/v1beta/models/gemini-pro:generateContent",
|
||||
expectedClass: "CLIENT_API",
|
||||
},
|
||||
{
|
||||
name: "/v1/v1 double-prefix",
|
||||
path: "/v1/v1",
|
||||
expectedClass: "CLIENT_API",
|
||||
expectedNormalized: "/api/v1",
|
||||
},
|
||||
{
|
||||
name: "/v1/v1/embeddings double-prefix",
|
||||
path: "/v1/v1/embeddings",
|
||||
expectedClass: "CLIENT_API",
|
||||
expectedNormalized: "/api/v1/embeddings",
|
||||
},
|
||||
{
|
||||
name: "/chat/completions alias",
|
||||
path: "/chat/completions",
|
||||
expectedClass: "CLIENT_API",
|
||||
expectedNormalized: "/api/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
name: "/responses alias",
|
||||
path: "/responses",
|
||||
expectedClass: "CLIENT_API",
|
||||
expectedNormalized: "/api/v1/responses",
|
||||
},
|
||||
{
|
||||
name: "/responses/abc alias",
|
||||
path: "/responses/abc",
|
||||
expectedClass: "CLIENT_API",
|
||||
expectedNormalized: "/api/v1/responses/abc",
|
||||
},
|
||||
{
|
||||
name: "/codex/* alias collapses to /api/v1/responses",
|
||||
path: "/codex/anything",
|
||||
expectedClass: "CLIENT_API",
|
||||
expectedNormalized: "/api/v1/responses",
|
||||
},
|
||||
{
|
||||
name: "/models alias",
|
||||
path: "/models",
|
||||
expectedClass: "CLIENT_API",
|
||||
expectedNormalized: "/api/v1/models",
|
||||
},
|
||||
|
||||
{
|
||||
name: "/api/auth/login is PUBLIC",
|
||||
path: "/api/auth/login",
|
||||
method: "POST",
|
||||
expectedClass: "PUBLIC",
|
||||
},
|
||||
{
|
||||
name: "/api/auth/logout is PUBLIC",
|
||||
path: "/api/auth/logout",
|
||||
method: "POST",
|
||||
expectedClass: "PUBLIC",
|
||||
},
|
||||
{
|
||||
name: "/api/auth/status is PUBLIC",
|
||||
path: "/api/auth/status",
|
||||
method: "GET",
|
||||
expectedClass: "PUBLIC",
|
||||
},
|
||||
{ name: "/api/init is PUBLIC", path: "/api/init", method: "POST", expectedClass: "PUBLIC" },
|
||||
{
|
||||
name: "/api/monitoring/health is PUBLIC",
|
||||
path: "/api/monitoring/health",
|
||||
method: "GET",
|
||||
expectedClass: "PUBLIC",
|
||||
},
|
||||
{
|
||||
name: "/api/cloud/auth POST is PUBLIC",
|
||||
path: "/api/cloud/auth",
|
||||
method: "POST",
|
||||
expectedClass: "PUBLIC",
|
||||
},
|
||||
{
|
||||
name: "/api/cloud/model/resolve POST is PUBLIC",
|
||||
path: "/api/cloud/model/resolve",
|
||||
method: "POST",
|
||||
expectedClass: "PUBLIC",
|
||||
},
|
||||
{
|
||||
name: "/api/cloud/models/alias GET is PUBLIC",
|
||||
path: "/api/cloud/models/alias",
|
||||
method: "GET",
|
||||
expectedClass: "PUBLIC",
|
||||
},
|
||||
{
|
||||
name: "/api/cloud/credentials/update PUT is MANAGEMENT",
|
||||
path: "/api/cloud/credentials/update",
|
||||
method: "PUT",
|
||||
expectedClass: "MANAGEMENT",
|
||||
},
|
||||
{
|
||||
name: "/api/cloud/models/alias PUT is MANAGEMENT",
|
||||
path: "/api/cloud/models/alias",
|
||||
method: "PUT",
|
||||
expectedClass: "MANAGEMENT",
|
||||
},
|
||||
{
|
||||
name: "/api/cloud/unknown GET is MANAGEMENT",
|
||||
path: "/api/cloud/unknown",
|
||||
method: "GET",
|
||||
expectedClass: "MANAGEMENT",
|
||||
},
|
||||
{
|
||||
name: "/api/oauth/* is PUBLIC",
|
||||
path: "/api/oauth/callback",
|
||||
method: "GET",
|
||||
expectedClass: "PUBLIC",
|
||||
},
|
||||
{
|
||||
name: "/api/sync/bundle is PUBLIC",
|
||||
path: "/api/sync/bundle",
|
||||
method: "POST",
|
||||
expectedClass: "PUBLIC",
|
||||
},
|
||||
{
|
||||
name: "/api/settings/require-login GET is PUBLIC readonly",
|
||||
path: "/api/settings/require-login",
|
||||
method: "GET",
|
||||
expectedClass: "PUBLIC",
|
||||
},
|
||||
{
|
||||
name: "/api/settings/require-login POST is MANAGEMENT",
|
||||
path: "/api/settings/require-login",
|
||||
method: "POST",
|
||||
expectedClass: "MANAGEMENT",
|
||||
},
|
||||
|
||||
{
|
||||
name: "/api/providers/* MUST stay MANAGEMENT",
|
||||
path: "/api/providers/openai",
|
||||
expectedClass: "MANAGEMENT",
|
||||
},
|
||||
{ name: "/api/keys MANAGEMENT", path: "/api/keys", expectedClass: "MANAGEMENT" },
|
||||
{ name: "/api/db/health MANAGEMENT", path: "/api/db/health", expectedClass: "MANAGEMENT" },
|
||||
{ name: "/api/settings MANAGEMENT", path: "/api/settings", expectedClass: "MANAGEMENT" },
|
||||
{ name: "/api/audit MANAGEMENT", path: "/api/audit", expectedClass: "MANAGEMENT" },
|
||||
|
||||
{
|
||||
name: "/api/usage/om-usage is PUBLIC (handler enforces its own API key auth)",
|
||||
path: "/api/usage/om-usage",
|
||||
method: "GET",
|
||||
expectedClass: "PUBLIC",
|
||||
},
|
||||
|
||||
{
|
||||
name: "Unknown top-level path defaults MANAGEMENT (fail-closed)",
|
||||
path: "/totally-unknown",
|
||||
expectedClass: "MANAGEMENT",
|
||||
},
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
test(`classifyRoute: ${c.name}`, () => {
|
||||
const r = classifyRoute(c.path, c.method ?? "GET");
|
||||
assert.equal(r.routeClass, c.expectedClass, `routeClass for ${c.path}`);
|
||||
if (c.expectedNormalized) {
|
||||
assert.equal(r.normalizedPath, c.expectedNormalized, `normalizedPath for ${c.path}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("classifyRoute returns deterministic result for trailing slash", () => {
|
||||
const a = classifyRoute("/api/v1/chat/completions", "POST");
|
||||
const b = classifyRoute("/api/v1/chat/completions/", "POST");
|
||||
assert.equal(a.routeClass, b.routeClass);
|
||||
assert.equal(a.normalizedPath, b.normalizedPath);
|
||||
});
|
||||
|
||||
test("classifyRoute strips trailing slash on root only when not '/'", () => {
|
||||
assert.equal(classifyRoute("/").normalizedPath, "/");
|
||||
});
|
||||
|
||||
test("classifyRoute treats /api/v1 prefix exactly", () => {
|
||||
assert.equal(classifyRoute("/api/v1abc").routeClass, "MANAGEMENT");
|
||||
assert.equal(classifyRoute("/api/v1/x").routeClass, "CLIENT_API");
|
||||
assert.equal(classifyRoute("/api/v1betamax").routeClass, "MANAGEMENT");
|
||||
assert.equal(classifyRoute("/api/v1beta/models").routeClass, "CLIENT_API");
|
||||
});
|
||||
|
||||
test("classify module public surface only exposes route classification", () => {
|
||||
assert.equal("classifyRoute" in classifyPublicApi, true);
|
||||
assert.equal("isClientApi" in classifyPublicApi, false);
|
||||
assert.equal("isManagement" in classifyPublicApi, false);
|
||||
assert.equal("isPublic" in classifyPublicApi, false);
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Issue #2257 — clientApi policy behavior when an invalid Bearer is sent and
|
||||
* REQUIRE_API_KEY=false.
|
||||
*
|
||||
* The existing `client-api-policy.test.ts` shares a DB-backed setup via
|
||||
* `resetStorage()` and `apiKeysDb` that has SQLite migration races on this
|
||||
* branch. This standalone file mocks `validateApiKey` to test the policy's
|
||||
* fallback branch in isolation — no DB, no migration runner.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import Module from "node:module";
|
||||
|
||||
// ─── Mock validateApiKey via require interception (so the dynamic import in
|
||||
// the policy module returns our stub instead of hitting the real DB module) ─
|
||||
|
||||
type ValidateFn = (key: string) => boolean | Promise<boolean>;
|
||||
let mockValidateApiKey: ValidateFn = () => false;
|
||||
|
||||
const originalResolve = (Module as unknown as { _resolveFilename: typeof Module._resolveFilename })
|
||||
._resolveFilename;
|
||||
|
||||
// Intercept require() / import() resolution for the apiKeys DB module and
|
||||
// substitute it for our stub. This runs only for the exact path the policy
|
||||
// imports — production code paths are unaffected.
|
||||
const POLICY_IMPORT_TARGET = "src/lib/db/apiKeys";
|
||||
|
||||
(Module as unknown as { _resolveFilename: typeof Module._resolveFilename })._resolveFilename =
|
||||
function patched(this: unknown, request: string, ...rest: unknown[]) {
|
||||
if (request.includes(POLICY_IMPORT_TARGET)) {
|
||||
// Resolve to a stub file we create below
|
||||
const stubPath = new URL("./__stub_apiKeys.mjs", import.meta.url).pathname;
|
||||
// @ts-expect-error - rest spread to original
|
||||
return originalResolve.call(this, stubPath, ...rest);
|
||||
}
|
||||
// @ts-expect-error - rest spread to original
|
||||
return originalResolve.call(this, request, ...rest);
|
||||
};
|
||||
|
||||
// Write the stub file ad-hoc (Node's loader needs a real file)
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-clientapi-policy-fallback-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const STUB_PATH = path.join(__dirname, "__stub_apiKeys.mjs");
|
||||
fs.writeFileSync(
|
||||
STUB_PATH,
|
||||
`export const validateApiKey = (key) => globalThis.__mockValidateApiKey(key);\n`
|
||||
);
|
||||
|
||||
// Wire the stub to our local variable
|
||||
(globalThis as unknown as { __mockValidateApiKey: ValidateFn }).__mockValidateApiKey = (key) =>
|
||||
mockValidateApiKey(key);
|
||||
|
||||
test.after(() => {
|
||||
try {
|
||||
fs.unlinkSync(STUB_PATH);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
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;
|
||||
});
|
||||
|
||||
// ─── Load policy fresh (after the interceptor is in place) ────────────────
|
||||
|
||||
async function loadPolicy() {
|
||||
const mod = await import(`../../../src/server/authz/policies/clientApi.ts?ts=${Date.now()}`);
|
||||
return mod.clientApiPolicy;
|
||||
}
|
||||
|
||||
function ctx(headers: Headers, normalizedPath = "/api/v1/chat/completions") {
|
||||
return {
|
||||
request: { method: "POST", headers, url: `http://localhost${normalizedPath}` },
|
||||
classification: {
|
||||
routeClass: "CLIENT_API" as const,
|
||||
reason: "client_api_v1" as const,
|
||||
normalizedPath,
|
||||
},
|
||||
requestId: "req_test",
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
test.beforeEach(() => {
|
||||
// Default to "every key fails" — individual tests override as needed.
|
||||
mockValidateApiKey = () => false;
|
||||
delete process.env.REQUIRE_API_KEY;
|
||||
});
|
||||
|
||||
test("#2257 — invalid bearer + REQUIRE_API_KEY=true → 401", async () => {
|
||||
process.env.REQUIRE_API_KEY = "true";
|
||||
const policy = await loadPolicy();
|
||||
const headers = new Headers({ authorization: "Bearer sk-stub-bogus" });
|
||||
const out = await policy.evaluate(ctx(headers));
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 401);
|
||||
assert.equal(out.code, "AUTH_002");
|
||||
}
|
||||
});
|
||||
|
||||
test("#2257 — invalid bearer + REQUIRE_API_KEY=false → anonymous (with warning log)", async () => {
|
||||
const originalWarn = console.warn;
|
||||
const warnings: string[] = [];
|
||||
console.warn = (msg: string) => warnings.push(String(msg));
|
||||
try {
|
||||
const policy = await loadPolicy();
|
||||
const headers = new Headers({ authorization: "Bearer sk-stub-bogus" });
|
||||
const out = await policy.evaluate(ctx(headers));
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "anonymous");
|
||||
assert.equal(out.subject.id, "local");
|
||||
}
|
||||
assert.ok(
|
||||
warnings.some((w) => w.includes("[clientApiPolicy]") && w.includes("REQUIRE_API_KEY=false")),
|
||||
"expected a warning about the fallback"
|
||||
);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
|
||||
test("#2257 — invalid x-api-key + REQUIRE_API_KEY=false → anonymous (with warning log)", async () => {
|
||||
const originalWarn = console.warn;
|
||||
const warnings: string[] = [];
|
||||
console.warn = (msg: string) => warnings.push(String(msg));
|
||||
try {
|
||||
const policy = await loadPolicy();
|
||||
const headers = new Headers({ "x-api-key": "sk-stub-bogus" });
|
||||
const out = await policy.evaluate(ctx(headers));
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "anonymous");
|
||||
assert.equal(out.subject.id, "local");
|
||||
}
|
||||
assert.ok(
|
||||
warnings.some((w) => w.includes("[clientApiPolicy]") && w.includes("REQUIRE_API_KEY=false")),
|
||||
"expected a warning about the fallback"
|
||||
);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
|
||||
test("#2257 — fallback warning masks the x-api-key (only last-4 in log)", async () => {
|
||||
const originalWarn = console.warn;
|
||||
const warnings: string[] = [];
|
||||
console.warn = (msg: string) => warnings.push(String(msg));
|
||||
try {
|
||||
const policy = await loadPolicy();
|
||||
const headers = new Headers({ "x-api-key": "sk-secretprefix-secretmiddle-XYZW" });
|
||||
const out = await policy.evaluate(ctx(headers));
|
||||
assert.equal(out.allow, true);
|
||||
assert.ok(
|
||||
warnings.every((w) => !w.includes("secretprefix") && !w.includes("secretmiddle")),
|
||||
"warning leaked the full bearer; only masked key id should be logged"
|
||||
);
|
||||
assert.ok(
|
||||
warnings.some((w) => w.includes("key_XYZW")),
|
||||
"expected masked key id (last-4) in the warning"
|
||||
);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
|
||||
test("#2257 — fallback warning masks the bearer (only last-4 in log)", async () => {
|
||||
const originalWarn = console.warn;
|
||||
const warnings: string[] = [];
|
||||
console.warn = (msg: string) => warnings.push(String(msg));
|
||||
try {
|
||||
const policy = await loadPolicy();
|
||||
const headers = new Headers({ authorization: "Bearer sk-secretprefix-secretmiddle-XYZW" });
|
||||
const out = await policy.evaluate(ctx(headers));
|
||||
assert.equal(out.allow, true);
|
||||
assert.ok(
|
||||
warnings.every((w) => !w.includes("secretprefix") && !w.includes("secretmiddle")),
|
||||
"warning leaked the full bearer; only masked key id should be logged"
|
||||
);
|
||||
assert.ok(
|
||||
warnings.some((w) => w.includes("key_XYZW")),
|
||||
"expected masked key id (last-4) in the warning"
|
||||
);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
|
||||
test("#2257 — no bearer + REQUIRE_API_KEY=false → anonymous (unchanged, no fallback warning)", async () => {
|
||||
const originalWarn = console.warn;
|
||||
const warnings: string[] = [];
|
||||
console.warn = (msg: string) => warnings.push(String(msg));
|
||||
try {
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(ctx(new Headers()));
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "anonymous");
|
||||
}
|
||||
// No warning should fire when no bearer is sent in the first place —
|
||||
// the warning is specifically for the "invalid-bearer-fell-through" case.
|
||||
assert.ok(
|
||||
warnings.every((w) => !w.includes("[clientApiPolicy]")),
|
||||
"no fallback warning expected when no bearer was sent"
|
||||
);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── #3504 — non-usable Authorization must NOT short-circuit the URL path token ─
|
||||
// VS Code Copilot sends its own (empty / non-OmniRoute) Authorization header even
|
||||
// when the OmniRoute key lives in the URL path of a /vscode tokenized endpoint.
|
||||
// A non-"Bearer <token>" Authorization must fall through to the URL token instead
|
||||
// of returning null and 401'ing under REQUIRE_API_KEY=true.
|
||||
|
||||
// validateApiKey is the real (no-DB → always-false) implementation here, so we
|
||||
// distinguish "URL token was extracted" from "no token found" by the rejection
|
||||
// MESSAGE: an extracted-but-unknown token → "Invalid API key"; nothing extracted
|
||||
// → "Authentication required". On the pre-fix code a non-Bearer Authorization
|
||||
// returned null, so these would all 401 with "Authentication required".
|
||||
|
||||
test("#3504 — empty 'Bearer ' Authorization falls through to the URL path token", async () => {
|
||||
process.env.REQUIRE_API_KEY = "true";
|
||||
const policy = await loadPolicy();
|
||||
const headers = new Headers({ authorization: "Bearer " });
|
||||
const out = await policy.evaluate(
|
||||
ctx(headers, "/api/v1/vscode/sk-url-token/chat/completions")
|
||||
);
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 401);
|
||||
assert.equal(
|
||||
out.message,
|
||||
"Invalid API key",
|
||||
"URL token must be extracted (→ 'Invalid API key'), not skipped (→ 'Authentication required')"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("#3504 — a non-Bearer scheme (Basic) also falls through to the URL token", async () => {
|
||||
process.env.REQUIRE_API_KEY = "true";
|
||||
const policy = await loadPolicy();
|
||||
const headers = new Headers({ authorization: "Basic Zm9vOmJhcg==" });
|
||||
const out = await policy.evaluate(
|
||||
ctx(headers, "/api/v1/vscode/sk-url-token/chat/completions")
|
||||
);
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) assert.equal(out.message, "Invalid API key");
|
||||
});
|
||||
|
||||
test("#3504 — non-Bearer Authorization with NO URL token still rejects as unauthenticated", async () => {
|
||||
process.env.REQUIRE_API_KEY = "true";
|
||||
const policy = await loadPolicy();
|
||||
const headers = new Headers({ authorization: "Bearer " });
|
||||
const out = await policy.evaluate(ctx(headers, "/api/v1/chat/completions"));
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) assert.equal(out.message, "Authentication required");
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
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(), "omr-clientapi-policy-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-secret";
|
||||
|
||||
const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts");
|
||||
const featureFlagsDb = await import("../../../src/lib/db/featureFlags.ts");
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
|
||||
const ORIGINAL_OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY;
|
||||
const ORIGINAL_ROUTER_API_KEY = process.env.ROUTER_API_KEY;
|
||||
const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
|
||||
const ORIGINAL_REQUIRE_API_KEY = process.env.REQUIRE_API_KEY;
|
||||
|
||||
function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
delete process.env.OMNIROUTE_API_KEY;
|
||||
delete process.env.ROUTER_API_KEY;
|
||||
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 });
|
||||
if (ORIGINAL_OMNIROUTE_API_KEY === undefined) delete process.env.OMNIROUTE_API_KEY;
|
||||
else process.env.OMNIROUTE_API_KEY = ORIGINAL_OMNIROUTE_API_KEY;
|
||||
if (ORIGINAL_ROUTER_API_KEY === undefined) delete process.env.ROUTER_API_KEY;
|
||||
else process.env.ROUTER_API_KEY = ORIGINAL_ROUTER_API_KEY;
|
||||
if (ORIGINAL_JWT_SECRET === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
|
||||
if (ORIGINAL_REQUIRE_API_KEY === undefined) delete process.env.REQUIRE_API_KEY;
|
||||
else process.env.REQUIRE_API_KEY = ORIGINAL_REQUIRE_API_KEY;
|
||||
});
|
||||
|
||||
async function loadPolicy() {
|
||||
const mod = await import(`../../../src/server/authz/policies/clientApi.ts?ts=${Date.now()}`);
|
||||
return mod.clientApiPolicy;
|
||||
}
|
||||
|
||||
function ctx(headers: Headers, method = "POST", normalizedPath = "/api/v1/chat/completions") {
|
||||
return {
|
||||
request: { method, headers, url: `http://localhost${normalizedPath}` },
|
||||
classification: {
|
||||
routeClass: "CLIENT_API" as const,
|
||||
reason: "client_api_v1" as const,
|
||||
normalizedPath,
|
||||
},
|
||||
requestId: "req_test",
|
||||
};
|
||||
}
|
||||
|
||||
async function dashboardCookie(): Promise<string> {
|
||||
process.env.JWT_SECRET = "client-api-dashboard-jwt-secret";
|
||||
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("clientApiPolicy: missing bearer is rejected with 401", async () => {
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(ctx(new Headers()));
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 401);
|
||||
assert.equal(out.code, "AUTH_002");
|
||||
}
|
||||
});
|
||||
|
||||
test("clientApiPolicy: REQUIRE_API_KEY DB feature flag override rejects anonymous", async () => {
|
||||
process.env.REQUIRE_API_KEY = "false";
|
||||
featureFlagsDb.setFeatureFlagOverride("REQUIRE_API_KEY", "true");
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(ctx(new Headers()));
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 401);
|
||||
assert.equal(out.code, "AUTH_002");
|
||||
}
|
||||
});
|
||||
|
||||
test("clientApiPolicy: REQUIRE_API_KEY DB feature flag override can disable env requirement", async () => {
|
||||
process.env.REQUIRE_API_KEY = "true";
|
||||
featureFlagsDb.setFeatureFlagOverride("REQUIRE_API_KEY", "false");
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(ctx(new Headers()));
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "anonymous");
|
||||
assert.equal(out.subject.id, "local");
|
||||
}
|
||||
});
|
||||
|
||||
test("clientApiPolicy: dashboard session can read the model catalog without bearer", async () => {
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers({ cookie: await dashboardCookie() }), "GET", "/api/v1/models")
|
||||
);
|
||||
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "dashboard_session");
|
||||
assert.equal(out.subject.id, "dashboard");
|
||||
}
|
||||
});
|
||||
|
||||
test("clientApiPolicy: dashboard session is accepted for client API routes without bearer", async () => {
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers({ cookie: await dashboardCookie() }), "POST", "/api/v1/chat/completions")
|
||||
);
|
||||
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "dashboard_session");
|
||||
assert.equal(out.subject.id, "dashboard");
|
||||
}
|
||||
});
|
||||
|
||||
test("clientApiPolicy: invalid bearer is rejected with 401", async () => {
|
||||
const policy = await loadPolicy();
|
||||
const headers = new Headers({ authorization: "Bearer sk-totally-bogus" });
|
||||
const out = await policy.evaluate(ctx(headers));
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 401);
|
||||
assert.equal(out.code, "AUTH_002");
|
||||
}
|
||||
});
|
||||
|
||||
test("clientApiPolicy: valid bearer is accepted as client_api_key subject", async () => {
|
||||
const created = await apiKeysDb.createApiKey("policy-test-key", "machine-test-1234");
|
||||
assert.ok(created?.key, "createApiKey must return a key");
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const headers = new Headers({ authorization: `Bearer ${created.key}` });
|
||||
const out = await policy.evaluate(ctx(headers));
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "client_api_key");
|
||||
assert.match(out.subject.id, /^key_/);
|
||||
}
|
||||
});
|
||||
|
||||
test("clientApiPolicy: revoked bearer is rejected", async () => {
|
||||
const created = await apiKeysDb.createApiKey("policy-revoked-key", "machine-revoked");
|
||||
assert.ok(await apiKeysDb.revokeApiKey(created.id));
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const headers = new Headers({ authorization: `Bearer ${created.key}` });
|
||||
const out = await policy.evaluate(ctx(headers));
|
||||
assert.equal(out.allow, false);
|
||||
});
|
||||
|
||||
test("clientApiPolicy: environment API key remains accepted for client API routes", async () => {
|
||||
process.env.OMNIROUTE_API_KEY = "sk-env-policy-test";
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers({ authorization: "Bearer sk-env-policy-test" }))
|
||||
);
|
||||
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "client_api_key");
|
||||
}
|
||||
});
|
||||
|
||||
test("clientApiPolicy: x-api-key header is accepted as client_api_key subject", async () => {
|
||||
const created = await apiKeysDb.createApiKey("policy-test-xkey", "machine-xkey-1234");
|
||||
assert.ok(created?.key, "createApiKey must return a key");
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const headers = new Headers({ "x-api-key": created.key });
|
||||
const out = await policy.evaluate(ctx(headers));
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "client_api_key");
|
||||
assert.match(out.subject.id, /^key_/);
|
||||
}
|
||||
});
|
||||
|
||||
test("clientApiPolicy: ROUTER_API_KEY remains accepted for client API routes", async () => {
|
||||
process.env.ROUTER_API_KEY = "sk-router-policy-test";
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers({ authorization: "Bearer sk-router-policy-test" }))
|
||||
);
|
||||
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "client_api_key");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it, beforeEach, after } from "node:test";
|
||||
|
||||
import { DASHBOARD_CSRF_HEADER } from "@/shared/constants/dashboardCsrf";
|
||||
import { issueDashboardCsrfToken, validateDashboardCsrfToken } from "@/server/authz/csrf";
|
||||
|
||||
const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.JWT_SECRET = "csrf-test-secret";
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (ORIGINAL_JWT_SECRET === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
|
||||
});
|
||||
|
||||
function request(path: string, cookie = "auth_token=session-a", token?: string): Request {
|
||||
return new Request(`http://127.0.0.1:20128${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie,
|
||||
...(token ? { [DASHBOARD_CSRF_HEADER]: token } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("dashboard CSRF tokens", () => {
|
||||
it("accepts a valid token for dashboard management mutation paths", () => {
|
||||
const issued = issueDashboardCsrfToken(request("/api/auth/csrf"), 1_000);
|
||||
|
||||
assert.ok(issued);
|
||||
assert.equal(
|
||||
validateDashboardCsrfToken(request("/api/models/test", undefined, issued.token), 1_000),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
validateDashboardCsrfToken(request("/api/models/test-all", undefined, issued.token), 1_000),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
validateDashboardCsrfToken(request("/api/combos/test", undefined, issued.token), 1_000),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
validateDashboardCsrfToken(request("/api/keys", undefined, issued.token), 1_000),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
validateDashboardCsrfToken(request("/api/settings", undefined, issued.token), 1_000),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("binds tokens to the dashboard auth cookie", () => {
|
||||
const issued = issueDashboardCsrfToken(
|
||||
request("/api/auth/csrf", "auth_token=session-a"),
|
||||
1_000
|
||||
);
|
||||
|
||||
assert.ok(issued);
|
||||
assert.equal(
|
||||
validateDashboardCsrfToken(
|
||||
request("/api/models/test", "auth_token=session-b", issued.token),
|
||||
1_000
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects expired and tampered tokens", () => {
|
||||
const issued = issueDashboardCsrfToken(request("/api/auth/csrf"), 1_000);
|
||||
|
||||
assert.ok(issued);
|
||||
assert.equal(
|
||||
validateDashboardCsrfToken(request("/api/models/test", undefined, issued.token), 700_000),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
validateDashboardCsrfToken(request("/api/models/test", undefined, `${issued.token}x`), 1_000),
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
isLocalOnlyPath,
|
||||
isLocalOnlyBypassableByManageScope,
|
||||
} from "@/server/authz/routeGuard";
|
||||
|
||||
// Security guard: the discovery surface must be strict-loopback only. If someone
|
||||
// removes "/api/discovery/" from LOCAL_ONLY_API_PREFIXES, or adds it to the
|
||||
// manage-scope bypass list, these assertions fail — the scan route makes
|
||||
// outbound probes (SSRF-adjacent) and must never be tunnel-reachable.
|
||||
describe("discovery routes are strict local-only", () => {
|
||||
for (const path of [
|
||||
"/api/discovery/results",
|
||||
"/api/discovery/results/42",
|
||||
"/api/discovery/scan",
|
||||
"/api/discovery/verify/42",
|
||||
]) {
|
||||
test(`isLocalOnlyPath("${path}") === true`, () => {
|
||||
assert.equal(isLocalOnlyPath(path), true);
|
||||
});
|
||||
|
||||
test(`"${path}" is NOT bypassable by manage-scope (strict loopback)`, () => {
|
||||
assert.equal(isLocalOnlyBypassableByManageScope(path), false);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// Regression for #6131 (Part B — enforcement): the IP blacklist was never wired
|
||||
// into the request pipeline, so blacklisted IPs were not actually blocked. This
|
||||
// locks that runAuthzPipeline blocks a blacklisted client IP with 403 before the
|
||||
// route policy runs, allows a clean IP through to the normal auth outcome, and
|
||||
// exempts loopback so the local operator can never lock themselves out.
|
||||
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 { NextRequest } from "next/server";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ipenforce-6131-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.JWT_SECRET = "test-secret-6131";
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const ipFilter = await import("../../../open-sse/services/ipFilter.ts");
|
||||
const pipeline = await import("../../../src/server/authz/pipeline.ts");
|
||||
|
||||
const ORIGINAL_STAMP_TOKEN = process.env.OMNIROUTE_PEER_STAMP_TOKEN;
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (ORIGINAL_STAMP_TOKEN === undefined) delete process.env.OMNIROUTE_PEER_STAMP_TOKEN;
|
||||
else process.env.OMNIROUTE_PEER_STAMP_TOKEN = ORIGINAL_STAMP_TOKEN;
|
||||
});
|
||||
|
||||
test.beforeEach(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
ipFilter.resetIPFilter();
|
||||
delete process.env.OMNIROUTE_PEER_STAMP_TOKEN;
|
||||
});
|
||||
|
||||
const BLOCKED = "203.0.113.9";
|
||||
const CLEAN = "203.0.113.10";
|
||||
|
||||
function req(xff: string, extraHeaders: Record<string, string> = {}) {
|
||||
return new NextRequest("http://localhost/v1/models", {
|
||||
headers: { "x-forwarded-for": xff, ...extraHeaders },
|
||||
});
|
||||
}
|
||||
|
||||
async function isIpBlocked(res: Response): Promise<boolean> {
|
||||
if (res.status !== 403) return false;
|
||||
const body = (await res.clone().json()) as { error?: unknown };
|
||||
// The IP-filter block returns a plain string error; policy 403s use a nested object.
|
||||
return typeof body.error === "string" && /blacklist|not in whitelist|banned/i.test(body.error);
|
||||
}
|
||||
|
||||
test("#6131 blacklisted remote IP is blocked with 403 before the route policy", async () => {
|
||||
ipFilter.configureIPFilter({ enabled: true, mode: "blacklist" });
|
||||
ipFilter.addToBlacklist(BLOCKED);
|
||||
|
||||
const res = await pipeline.runAuthzPipeline(req(BLOCKED), { enforce: true });
|
||||
assert.equal(res.status, 403);
|
||||
assert.equal(await isIpBlocked(res), true, "expected the IP-filter 403 block");
|
||||
});
|
||||
|
||||
test("#6131 a clean remote IP passes the IP filter (reaches the normal auth outcome)", async () => {
|
||||
ipFilter.configureIPFilter({ enabled: true, mode: "blacklist" });
|
||||
ipFilter.addToBlacklist(BLOCKED);
|
||||
|
||||
const res = await pipeline.runAuthzPipeline(req(CLEAN), { enforce: true });
|
||||
// The IP filter must let it through to the normal route/auth outcome, whatever
|
||||
// that is (the point is: NOT the IP-filter 403 block).
|
||||
assert.equal(await isIpBlocked(res), false, "clean IP must not be blocked by the IP filter");
|
||||
});
|
||||
|
||||
test("#6131 disabled filter never blocks (even a listed IP)", async () => {
|
||||
ipFilter.configureIPFilter({ enabled: false, mode: "blacklist" });
|
||||
ipFilter.addToBlacklist(BLOCKED);
|
||||
|
||||
const res = await pipeline.runAuthzPipeline(req(BLOCKED), { enforce: true });
|
||||
assert.equal(await isIpBlocked(res), false, "disabled filter must not block");
|
||||
});
|
||||
|
||||
test("#6131 loopback is exempt — operator can't lock themselves out locally", async () => {
|
||||
process.env.OMNIROUTE_PEER_STAMP_TOKEN = "stamp-tok";
|
||||
ipFilter.configureIPFilter({ enabled: true, mode: "blacklist" });
|
||||
ipFilter.addToBlacklist(BLOCKED);
|
||||
|
||||
// A trusted stamped loopback peer IP downgrades the request to "loopback".
|
||||
const res = await pipeline.runAuthzPipeline(
|
||||
req(BLOCKED, { "x-omniroute-peer-ip": "stamp-tok|127.0.0.1" }),
|
||||
{ enforce: true }
|
||||
);
|
||||
assert.equal(await isIpBlocked(res), false, "loopback must be exempt from the IP filter");
|
||||
});
|
||||
@@ -0,0 +1,432 @@
|
||||
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(), "omr-mgmt-policy-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-secret";
|
||||
// API-key validation falls through to a Redis-backed cache otherwise — disable
|
||||
// it for the local test loop so isValidApiKey() does not stall on ETIMEDOUT.
|
||||
process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts");
|
||||
const settingsDb = await import("../../../src/lib/db/settings.ts");
|
||||
const modelSync = await import("../../../src/shared/services/modelSyncScheduler.ts");
|
||||
|
||||
const ORIGINAL_JWT = process.env.JWT_SECRET;
|
||||
const ORIGINAL_INITIAL = process.env.INITIAL_PASSWORD;
|
||||
|
||||
function reset() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
delete process.env.JWT_SECRET;
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
reset();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (ORIGINAL_JWT === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = ORIGINAL_JWT;
|
||||
if (ORIGINAL_INITIAL === undefined) delete process.env.INITIAL_PASSWORD;
|
||||
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL;
|
||||
});
|
||||
|
||||
async function loadPolicy() {
|
||||
const mod = await import(`../../../src/server/authz/policies/management.ts?ts=${Date.now()}`);
|
||||
return mod.managementPolicy;
|
||||
}
|
||||
|
||||
async function dashboardCookieHeader(expiresIn = "1h"): Promise<string> {
|
||||
// Mirrors tests/unit/authz/pipeline.test.ts: mint a real HS256 auth_token
|
||||
// JWT against process.env.JWT_SECRET so isDashboardSessionAuthenticated()
|
||||
// accepts it. The header path is sufficient — the policy reads the cookie
|
||||
// from `request.headers.get("cookie")` when there's no `request.cookies`
|
||||
// accessor on the plain ctx() object.
|
||||
assert.ok(
|
||||
process.env.JWT_SECRET,
|
||||
"JWT_SECRET must be set before minting dashboard cookie (otherwise TextEncoder would encode the string 'undefined' and silently mint a wrong-secret JWT)"
|
||||
);
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
const token = await new SignJWT({ authenticated: true })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime(expiresIn)
|
||||
.sign(secret);
|
||||
return `auth_token=${token}`;
|
||||
}
|
||||
|
||||
function ctx(
|
||||
headers: Headers,
|
||||
method = "GET",
|
||||
path = "/api/keys",
|
||||
requestExtras: Record<string, unknown> = {}
|
||||
) {
|
||||
return {
|
||||
request: {
|
||||
method,
|
||||
headers,
|
||||
url: `http://localhost${path}`,
|
||||
nextUrl: { pathname: path },
|
||||
...requestExtras,
|
||||
},
|
||||
classification: {
|
||||
routeClass: "MANAGEMENT" as const,
|
||||
reason: path.startsWith("/dashboard")
|
||||
? ("dashboard_prefix" as const)
|
||||
: ("management_api" as const),
|
||||
normalizedPath: path,
|
||||
},
|
||||
requestId: "req_test",
|
||||
};
|
||||
}
|
||||
|
||||
function remoteCtx(headers: Headers, method = "GET", path = "/api/keys") {
|
||||
return {
|
||||
request: {
|
||||
method,
|
||||
headers,
|
||||
url: `https://dashboard.example${path}`,
|
||||
nextUrl: { hostname: "dashboard.example", pathname: path },
|
||||
},
|
||||
classification: {
|
||||
routeClass: "MANAGEMENT" as const,
|
||||
reason: path.startsWith("/dashboard")
|
||||
? ("dashboard_prefix" as const)
|
||||
: ("management_api" as const),
|
||||
normalizedPath: path,
|
||||
},
|
||||
requestId: "req_remote_test",
|
||||
};
|
||||
}
|
||||
|
||||
test("managementPolicy: allows when auth not required (no password set)", async () => {
|
||||
await settingsDb.updateSettings({ requireLogin: true, password: null });
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(ctx(new Headers()));
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "anonymous");
|
||||
assert.equal(out.subject.label, "auth-disabled");
|
||||
}
|
||||
});
|
||||
|
||||
test("managementPolicy: rejects remote fresh bootstrap without a password", async () => {
|
||||
await settingsDb.updateSettings({ requireLogin: true, password: null });
|
||||
const policy = await loadPolicy();
|
||||
|
||||
const out = await policy.evaluate(remoteCtx(new Headers()));
|
||||
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 401);
|
||||
assert.equal(out.code, "AUTH_001");
|
||||
}
|
||||
});
|
||||
|
||||
test("managementPolicy: rejects 401 when auth required and no credentials", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(ctx(new Headers()));
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 401);
|
||||
assert.equal(out.code, "AUTH_001");
|
||||
}
|
||||
});
|
||||
|
||||
test("managementPolicy: rejects client API keys for dashboard access", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
const created = await apiKeysDb.createApiKey("dashboard-denied", "machine-dashboard-denied");
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers({ authorization: `Bearer ${created.key}` }), "GET", "/dashboard")
|
||||
);
|
||||
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 403);
|
||||
assert.equal(out.code, "AUTH_001");
|
||||
}
|
||||
});
|
||||
|
||||
test("managementPolicy: allows API keys with manage scope", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
const created = await apiKeysDb.createApiKey("mgmt-key", "machine-mgmt-allow", ["manage"]);
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers({ authorization: `Bearer ${created.key}` }), "POST", "/api/keys")
|
||||
);
|
||||
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "management_key");
|
||||
assert.equal(out.subject.label, "api-key-manage-scope");
|
||||
assert.equal(out.subject.id, created.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("managementPolicy: rejects valid API keys that lack manage scope", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
const created = await apiKeysDb.createApiKey("no-scope-key", "machine-no-scope", []);
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers({ authorization: `Bearer ${created.key}` }), "POST", "/api/keys")
|
||||
);
|
||||
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
// A valid bearer is present but its scope is insufficient → 403.
|
||||
assert.equal(out.status, 403);
|
||||
assert.equal(out.code, "AUTH_001");
|
||||
}
|
||||
});
|
||||
|
||||
test("managementPolicy: rejects invalid API keys with 403 when bearer is present", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers({ authorization: "Bearer not-a-real-key" }), "POST", "/api/keys")
|
||||
);
|
||||
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 403);
|
||||
assert.equal(out.code, "AUTH_001");
|
||||
}
|
||||
});
|
||||
|
||||
// ─── LOCAL_ONLY manage-scope bypass for /api/mcp/* ───────────────────────────
|
||||
//
|
||||
// `/api/mcp/*` is in LOCAL_ONLY_API_PREFIXES (because it can spawn child
|
||||
// processes for unauthenticated callers) AND in
|
||||
// LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES (so a manage-scoped API key
|
||||
// presented from non-loopback may reach it). `/api/cli-tools/runtime/*` is
|
||||
// LOCAL_ONLY but NOT bypassable — the carve-out is path-scoped.
|
||||
//
|
||||
// `ctx()` uses `new Headers()` without an explicit `host`, so
|
||||
// `isLoopbackHost(null)` returns false → the policy treats it as non-loopback,
|
||||
// which is the exact case this block exercises.
|
||||
|
||||
test("LOCAL_ONLY manage-scope bypass: no Bearer + non-loopback → 403 (regression guard)", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(ctx(new Headers(), "GET", "/api/mcp/stream"));
|
||||
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 403);
|
||||
assert.equal(out.code, "LOCAL_ONLY");
|
||||
}
|
||||
});
|
||||
|
||||
test("LOCAL_ONLY manage-scope bypass: non-manage key + non-loopback → 403", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
const created = await apiKeysDb.createApiKey("chat-only", "machine-chat-only", ["chat"]);
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers({ authorization: `Bearer ${created.key}` }), "GET", "/api/mcp/stream")
|
||||
);
|
||||
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 403);
|
||||
assert.equal(out.code, "LOCAL_ONLY");
|
||||
}
|
||||
});
|
||||
|
||||
test("LOCAL_ONLY manage-scope bypass: manage-scope key + non-loopback → allow", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
const created = await apiKeysDb.createApiKey("mcp-bypass-key", "machine-mcp-bypass", ["manage"]);
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers({ authorization: `Bearer ${created.key}` }), "GET", "/api/mcp/stream")
|
||||
);
|
||||
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "management_key");
|
||||
assert.equal(out.subject.id, created.id);
|
||||
assert.ok(
|
||||
(out.subject.label ?? "").includes("local-only-bypass"),
|
||||
`expected label to include 'local-only-bypass', got ${out.subject.label}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("LOCAL_ONLY manage-scope bypass: carve-out does not extend to /api/cli-tools/runtime/*", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
const created = await apiKeysDb.createApiKey("cli-runtime-denied", "machine-cli-runtime-denied", [
|
||||
"manage",
|
||||
]);
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(
|
||||
new Headers({ authorization: `Bearer ${created.key}` }),
|
||||
"GET",
|
||||
"/api/cli-tools/runtime/foo"
|
||||
)
|
||||
);
|
||||
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 403);
|
||||
assert.equal(out.code, "LOCAL_ONLY");
|
||||
}
|
||||
});
|
||||
|
||||
test("LOCAL_ONLY manage-scope bypass: loopback + no Bearer → allow (local CLI flow preserved)", async () => {
|
||||
// Match the fresh-bootstrap pattern used by the "allows when auth not
|
||||
// required" test above: no password configured + loopback request →
|
||||
// `isAuthRequired` returns false → anonymous-allow fires once the LOCAL_ONLY
|
||||
// gate is satisfied. Locality comes from the real peer (socket.remoteAddress)
|
||||
// under the peer-stamp model (2026-05-31) — the spoofable `host` header alone
|
||||
// is deliberately NOT enough.
|
||||
await settingsDb.updateSettings({ requireLogin: true, password: null });
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers({ host: "localhost:20128" }), "GET", "/api/mcp/stream", {
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(out.allow, true);
|
||||
});
|
||||
|
||||
// ─── LOCAL_ONLY dashboard-session bypass ─────────────────────────────────────
|
||||
//
|
||||
// Regression cover for commit ca284a91 ("refine LOCAL_ONLY bypass — dashboard
|
||||
// cookie + admin label + error log"). The dashboard-session bypass mirrors the
|
||||
// manage-scope bypass: an authenticated `auth_token` cookie reaching a
|
||||
// bypassable LOCAL_ONLY path (e.g. /api/mcp/status) from a public hostname is
|
||||
// allowed, but the cli-tools-runtime carve-out is NOT extended to it.
|
||||
|
||||
test("LOCAL_ONLY dashboard-session bypass: authenticated dashboard cookie + non-loopback → allow", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
const cookie = await dashboardCookieHeader();
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(ctx(new Headers({ cookie }), "GET", "/api/mcp/stream"));
|
||||
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "dashboard_session");
|
||||
assert.equal(out.subject.id, "dashboard");
|
||||
assert.equal(out.subject.label, "dashboard-session-local-only-bypass");
|
||||
}
|
||||
});
|
||||
|
||||
test("LOCAL_ONLY dashboard-session bypass: authenticated dashboard cookie + /api/cli-tools/runtime/ → 403 LOCAL_ONLY", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
const cookie = await dashboardCookieHeader();
|
||||
const policy = await loadPolicy();
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers({ cookie }), "GET", "/api/cli-tools/runtime/foo")
|
||||
);
|
||||
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.status, 403);
|
||||
assert.equal(out.code, "LOCAL_ONLY");
|
||||
}
|
||||
});
|
||||
|
||||
test("managementPolicy: allows internal model sync only on the dedicated provider routes", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
const policy = await loadPolicy();
|
||||
const internalHeaders = new Headers(modelSync.buildModelSyncInternalHeaders());
|
||||
|
||||
const allowed = await policy.evaluate(
|
||||
ctx(internalHeaders, "POST", "/api/providers/conn-123/sync-models")
|
||||
);
|
||||
assert.equal(allowed.allow, true);
|
||||
if (allowed.allow) {
|
||||
assert.equal(allowed.subject.kind, "management_key");
|
||||
assert.equal(allowed.subject.id, "model-sync");
|
||||
}
|
||||
|
||||
const denied = await policy.evaluate(ctx(internalHeaders, "POST", "/api/keys"));
|
||||
assert.equal(denied.allow, false);
|
||||
});
|
||||
|
||||
const INGEST_PATH = "/api/tools/traffic-inspector/internal/ingest";
|
||||
|
||||
test("managementPolicy: allows loopback inspector ingest without a dashboard session (D4)", async () => {
|
||||
// Auth is required (password set), and there is NO dashboard cookie / API key.
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-ingest";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
const policy = await loadPolicy();
|
||||
// Loopback peer (socket.remoteAddress) + ingest path → exempt from management
|
||||
// auth; the route handler validates the shared-secret ingest token.
|
||||
const out = await policy.evaluate(
|
||||
ctx(new Headers(), "POST", INGEST_PATH, { socket: { remoteAddress: "127.0.0.1" } })
|
||||
);
|
||||
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.id, "inspector-ingest");
|
||||
assert.equal(out.subject.label, "inspector-ingest-token");
|
||||
}
|
||||
});
|
||||
|
||||
test("managementPolicy: rejects remote inspector ingest as LOCAL_ONLY (D4)", async () => {
|
||||
process.env.JWT_SECRET = "test-jwt-secret-for-ingest";
|
||||
process.env.INITIAL_PASSWORD = "initial-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
|
||||
const policy = await loadPolicy();
|
||||
// Non-loopback caller hits the LOCAL_ONLY gate before the ingest carve-out —
|
||||
// the loopback exemption must never widen the endpoint to off-box peers.
|
||||
const out = await policy.evaluate(remoteCtx(new Headers(), "POST", INGEST_PATH));
|
||||
|
||||
assert.equal(out.allow, false);
|
||||
if (!out.allow) {
|
||||
assert.equal(out.code, "LOCAL_ONLY");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,566 @@
|
||||
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";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-authz-pipeline-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-secret";
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts");
|
||||
const settingsDb = await import("../../../src/lib/db/settings.ts");
|
||||
const pipeline = await import("../../../src/server/authz/pipeline.ts");
|
||||
const csrf = await import("../../../src/server/authz/csrf.ts");
|
||||
const dashboardCsrfConstants = await import("../../../src/shared/constants/dashboardCsrf.ts");
|
||||
|
||||
const ORIGINAL_JWT = process.env.JWT_SECRET;
|
||||
const ORIGINAL_INITIAL = process.env.INITIAL_PASSWORD;
|
||||
const ORIGINAL_AUTH_COOKIE_SECURE = process.env.AUTH_COOKIE_SECURE;
|
||||
const ORIGINAL_REQUIRE_API_KEY = process.env.REQUIRE_API_KEY;
|
||||
const ORIGINAL_OMNIROUTE_PUBLIC_BASE_URL = process.env.OMNIROUTE_PUBLIC_BASE_URL;
|
||||
const ORIGINAL_NEXT_PUBLIC_BASE_URL = process.env.NEXT_PUBLIC_BASE_URL;
|
||||
const ORIGINAL_NEXT_PUBLIC_APP_URL = process.env.NEXT_PUBLIC_APP_URL;
|
||||
const ORIGINAL_OMNIROUTE_TRUST_PROXY = process.env.OMNIROUTE_TRUST_PROXY;
|
||||
const ORIGINAL_OMNIROUTE_PEER_STAMP_TOKEN = process.env.OMNIROUTE_PEER_STAMP_TOKEN;
|
||||
|
||||
function resetEnvironment() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
process.env.JWT_SECRET = "pipeline-jwt-secret";
|
||||
process.env.INITIAL_PASSWORD = "pipeline-initial-password";
|
||||
process.env.REQUIRE_API_KEY = "true";
|
||||
delete process.env.AUTH_COOKIE_SECURE;
|
||||
delete process.env.OMNIROUTE_PUBLIC_BASE_URL;
|
||||
delete process.env.NEXT_PUBLIC_BASE_URL;
|
||||
delete process.env.NEXT_PUBLIC_APP_URL;
|
||||
delete process.env.OMNIROUTE_TRUST_PROXY;
|
||||
delete process.env.OMNIROUTE_PEER_STAMP_TOKEN;
|
||||
globalThis.__omnirouteShutdown = { init: false, shuttingDown: false, activeRequests: 0 };
|
||||
}
|
||||
|
||||
async function forceAuthRequired() {
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
}
|
||||
|
||||
async function dashboardCookie(expiresIn = "1h"): Promise<string> {
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
const token = await new SignJWT({ authenticated: true })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime(expiresIn)
|
||||
.sign(secret);
|
||||
return `auth_token=${token}`;
|
||||
}
|
||||
|
||||
function request(url: string, init?: RequestInit): NextRequest {
|
||||
return new NextRequest(url, init);
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetEnvironment();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (ORIGINAL_JWT === undefined) delete process.env.JWT_SECRET;
|
||||
else process.env.JWT_SECRET = ORIGINAL_JWT;
|
||||
if (ORIGINAL_INITIAL === undefined) delete process.env.INITIAL_PASSWORD;
|
||||
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL;
|
||||
if (ORIGINAL_AUTH_COOKIE_SECURE === undefined) delete process.env.AUTH_COOKIE_SECURE;
|
||||
else process.env.AUTH_COOKIE_SECURE = ORIGINAL_AUTH_COOKIE_SECURE;
|
||||
if (ORIGINAL_REQUIRE_API_KEY === undefined) delete process.env.REQUIRE_API_KEY;
|
||||
else process.env.REQUIRE_API_KEY = ORIGINAL_REQUIRE_API_KEY;
|
||||
if (ORIGINAL_OMNIROUTE_PUBLIC_BASE_URL === undefined)
|
||||
delete process.env.OMNIROUTE_PUBLIC_BASE_URL;
|
||||
else process.env.OMNIROUTE_PUBLIC_BASE_URL = ORIGINAL_OMNIROUTE_PUBLIC_BASE_URL;
|
||||
if (ORIGINAL_NEXT_PUBLIC_BASE_URL === undefined) delete process.env.NEXT_PUBLIC_BASE_URL;
|
||||
else process.env.NEXT_PUBLIC_BASE_URL = ORIGINAL_NEXT_PUBLIC_BASE_URL;
|
||||
if (ORIGINAL_NEXT_PUBLIC_APP_URL === undefined) delete process.env.NEXT_PUBLIC_APP_URL;
|
||||
else process.env.NEXT_PUBLIC_APP_URL = ORIGINAL_NEXT_PUBLIC_APP_URL;
|
||||
if (ORIGINAL_OMNIROUTE_TRUST_PROXY === undefined) delete process.env.OMNIROUTE_TRUST_PROXY;
|
||||
else process.env.OMNIROUTE_TRUST_PROXY = ORIGINAL_OMNIROUTE_TRUST_PROXY;
|
||||
if (ORIGINAL_OMNIROUTE_PEER_STAMP_TOKEN === undefined) {
|
||||
delete process.env.OMNIROUTE_PEER_STAMP_TOKEN;
|
||||
} else {
|
||||
process.env.OMNIROUTE_PEER_STAMP_TOKEN = ORIGINAL_OMNIROUTE_PEER_STAMP_TOKEN;
|
||||
}
|
||||
globalThis.__omnirouteShutdown = { init: false, shuttingDown: false, activeRequests: 0 };
|
||||
});
|
||||
|
||||
test("runAuthzPipeline redirects root to dashboard before management auth", async () => {
|
||||
await forceAuthRequired();
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(request("http://localhost/"), { enforce: true });
|
||||
|
||||
assert.equal(response.status, 307);
|
||||
assert.equal(response.headers.get("location"), "http://localhost/dashboard");
|
||||
});
|
||||
|
||||
test("runAuthzPipeline redirects unauthenticated dashboard pages to login", async () => {
|
||||
await forceAuthRequired();
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(request("http://localhost/dashboard"), {
|
||||
enforce: true,
|
||||
});
|
||||
|
||||
assert.equal(response.status, 307);
|
||||
assert.equal(response.headers.get("location"), "http://localhost/login");
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT");
|
||||
assert.ok(response.headers.get("x-request-id"));
|
||||
});
|
||||
|
||||
test("runAuthzPipeline redirects unauthenticated /home to login (#2712)", async () => {
|
||||
await forceAuthRequired();
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(request("http://localhost/home"), {
|
||||
enforce: true,
|
||||
});
|
||||
|
||||
assert.equal(response.status, 307);
|
||||
assert.equal(response.headers.get("location"), "http://localhost/login");
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT");
|
||||
});
|
||||
|
||||
test("runAuthzPipeline redirects unauthenticated /home/* nested paths to login (#2712)", async () => {
|
||||
await forceAuthRequired();
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(request("http://localhost/home/settings"), {
|
||||
enforce: true,
|
||||
});
|
||||
|
||||
assert.equal(response.status, 307);
|
||||
assert.equal(response.headers.get("location"), "http://localhost/login");
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT");
|
||||
});
|
||||
|
||||
// PR #1810 (upstream 9router): reverse-proxy subpath deployment via
|
||||
// OMNIROUTE_BASE_PATH. Next.js strips the basePath from nextUrl.pathname
|
||||
// before route classification, so the redirect targets must re-add it via
|
||||
// request.nextUrl.basePath to stay inside the deployed subpath.
|
||||
test("runAuthzPipeline prefixes the root-to-dashboard redirect with basePath when set", async () => {
|
||||
await forceAuthRequired();
|
||||
|
||||
const req = new NextRequest("http://localhost/omniroute/", {
|
||||
nextConfig: { basePath: "/omniroute" },
|
||||
});
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(req, { enforce: true });
|
||||
|
||||
assert.equal(response.status, 307);
|
||||
assert.equal(response.headers.get("location"), "http://localhost/omniroute/dashboard");
|
||||
});
|
||||
|
||||
test("runAuthzPipeline prefixes the dashboard login redirect with basePath when set", async () => {
|
||||
await forceAuthRequired();
|
||||
|
||||
const req = new NextRequest("http://localhost/omniroute/dashboard", {
|
||||
nextConfig: { basePath: "/omniroute" },
|
||||
});
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(req, { enforce: true });
|
||||
|
||||
assert.equal(response.status, 307);
|
||||
assert.equal(response.headers.get("location"), "http://localhost/omniroute/login");
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT");
|
||||
});
|
||||
|
||||
test("runAuthzPipeline leaves redirect targets unprefixed when basePath is empty", async () => {
|
||||
await forceAuthRequired();
|
||||
|
||||
const req = new NextRequest("http://localhost/dashboard", {
|
||||
nextConfig: { basePath: "" },
|
||||
});
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(req, { enforce: true });
|
||||
|
||||
assert.equal(response.status, 307);
|
||||
assert.equal(response.headers.get("location"), "http://localhost/login");
|
||||
});
|
||||
|
||||
test("runAuthzPipeline allows onboarding when login is required but no password exists", async () => {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
await settingsDb.updateSettings({
|
||||
requireLogin: true,
|
||||
setupComplete: true,
|
||||
password: "",
|
||||
});
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request("https://example.com/dashboard/onboarding"),
|
||||
{ enforce: true }
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "PUBLIC");
|
||||
});
|
||||
|
||||
test("runAuthzPipeline allows first password writes when login is required but no password exists", async () => {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
await settingsDb.updateSettings({
|
||||
requireLogin: true,
|
||||
setupComplete: true,
|
||||
password: "",
|
||||
});
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request("https://example.com/api/settings/require-login", { method: "POST" }),
|
||||
{ enforce: true }
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT");
|
||||
});
|
||||
|
||||
test("runAuthzPipeline keeps management API rejections as JSON", async () => {
|
||||
await forceAuthRequired();
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(request("http://localhost/api/keys"), {
|
||||
enforce: true,
|
||||
});
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.equal(response.headers.get("content-type")?.includes("application/json"), true);
|
||||
assert.equal(body.error.code, "AUTH_001");
|
||||
});
|
||||
|
||||
test("runAuthzPipeline rejects oversized API bodies before auth", async () => {
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request("http://localhost/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-length": String(99 * 1024 * 1024),
|
||||
origin: "https://app.example.com",
|
||||
},
|
||||
}),
|
||||
{ enforce: true }
|
||||
);
|
||||
|
||||
assert.equal(response.status, 413);
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "CLIENT_API");
|
||||
assert.ok(response.headers.get("x-request-id"));
|
||||
assert.equal(
|
||||
response.headers.get("Access-Control-Allow-Methods"),
|
||||
"GET, POST, PUT, DELETE, PATCH, OPTIONS"
|
||||
);
|
||||
});
|
||||
|
||||
test("runAuthzPipeline rejects oversized rewritten alias API bodies before auth", async () => {
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request("http://localhost/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-length": String(99 * 1024 * 1024),
|
||||
origin: "https://app.example.com",
|
||||
},
|
||||
}),
|
||||
{ enforce: true }
|
||||
);
|
||||
|
||||
assert.equal(response.status, 413);
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "CLIENT_API");
|
||||
assert.ok(response.headers.get("x-request-id"));
|
||||
});
|
||||
|
||||
test("runAuthzPipeline rejects unauthenticated v1beta Gemini aliases as client API", async () => {
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request("http://localhost/v1beta/models/gemini-pro:generateContent", {
|
||||
method: "POST",
|
||||
}),
|
||||
{ enforce: true }
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "CLIENT_API");
|
||||
assert.equal(body.error.code, "AUTH_002");
|
||||
});
|
||||
|
||||
test("runAuthzPipeline rejects unauthenticated internal api v1beta routes as client API", async () => {
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request("http://localhost/api/v1beta/models/gemini-pro:generateContent", {
|
||||
method: "POST",
|
||||
}),
|
||||
{ enforce: true }
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "CLIENT_API");
|
||||
assert.equal(body.error.code, "AUTH_002");
|
||||
});
|
||||
|
||||
test("runAuthzPipeline rejects new API requests during shutdown drain", async () => {
|
||||
globalThis.__omnirouteShutdown = { init: true, shuttingDown: true, activeRequests: 0 };
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(request("http://localhost/api/v1/models"), {
|
||||
enforce: true,
|
||||
});
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal(body.error.code, "SERVICE_UNAVAILABLE");
|
||||
});
|
||||
|
||||
test("runAuthzPipeline rejects rewritten API aliases during shutdown drain", async () => {
|
||||
globalThis.__omnirouteShutdown = { init: true, shuttingDown: true, activeRequests: 0 };
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(request("http://localhost/responses"), {
|
||||
enforce: true,
|
||||
});
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "CLIENT_API");
|
||||
assert.equal(body.error.code, "SERVICE_UNAVAILABLE");
|
||||
});
|
||||
|
||||
test("runAuthzPipeline allows dashboard sessions to read model catalog aliases", async () => {
|
||||
await forceAuthRequired();
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request("http://localhost/v1/models", {
|
||||
headers: { cookie: await dashboardCookie() },
|
||||
}),
|
||||
{ enforce: true }
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "CLIENT_API");
|
||||
});
|
||||
|
||||
test("runAuthzPipeline allows dashboard sessions to reach DB health management API", async () => {
|
||||
await forceAuthRequired();
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request("http://localhost/api/db/health", {
|
||||
headers: { cookie: await dashboardCookie() },
|
||||
}),
|
||||
{ enforce: true }
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT");
|
||||
});
|
||||
|
||||
test("runAuthzPipeline accepts dashboard mutations from configured public origin", async () => {
|
||||
await forceAuthRequired();
|
||||
process.env.NEXT_PUBLIC_BASE_URL = "https://gateway.example.test";
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request("http://omniroute:20128/api/providers/health-autopilot/actions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: await dashboardCookie(),
|
||||
origin: "https://gateway.example.test",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
}),
|
||||
{ enforce: true }
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT");
|
||||
});
|
||||
|
||||
test("runAuthzPipeline rejects dashboard mutations from dynamic public origins without CSRF", async () => {
|
||||
await forceAuthRequired();
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request("http://127.0.0.1:20128/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
cookie: await dashboardCookie(),
|
||||
host: "127.0.0.1:20128",
|
||||
origin: "https://random-tunnel.example.test",
|
||||
"content-type": "application/json",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
body: "{}",
|
||||
}),
|
||||
{ enforce: true }
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.equal(body.error.code, "INVALID_ORIGIN");
|
||||
});
|
||||
|
||||
test("runAuthzPipeline accepts dashboard mutations from dynamic public origins with CSRF", async () => {
|
||||
await forceAuthRequired();
|
||||
|
||||
const cookie = await dashboardCookie();
|
||||
const issued = csrf.issueDashboardCsrfToken(
|
||||
request("http://127.0.0.1:20128/api/auth/csrf", {
|
||||
headers: { cookie },
|
||||
})
|
||||
);
|
||||
assert.ok(issued);
|
||||
|
||||
for (const [method, path] of [
|
||||
["POST", "/api/models/test"],
|
||||
["POST", "/api/keys"],
|
||||
["PATCH", "/api/settings"],
|
||||
["PUT", "/api/combos/combo-1"],
|
||||
["DELETE", "/api/webhooks/webhook-1"],
|
||||
] as const) {
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request(`http://127.0.0.1:20128${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
cookie,
|
||||
host: "127.0.0.1:20128",
|
||||
origin: "https://random-tunnel.example.test",
|
||||
"content-type": "application/json",
|
||||
[dashboardCsrfConstants.DASHBOARD_CSRF_HEADER]: issued.token,
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
body: "{}",
|
||||
}),
|
||||
{ enforce: true }
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200, path);
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT");
|
||||
}
|
||||
});
|
||||
|
||||
test("runAuthzPipeline does not let CSRF bypass cross-site fetch metadata", async () => {
|
||||
await forceAuthRequired();
|
||||
|
||||
const cookie = await dashboardCookie();
|
||||
const issued = csrf.issueDashboardCsrfToken(
|
||||
request("http://127.0.0.1:20128/api/auth/csrf", {
|
||||
headers: { cookie },
|
||||
})
|
||||
);
|
||||
assert.ok(issued);
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request("http://127.0.0.1:20128/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
cookie,
|
||||
host: "127.0.0.1:20128",
|
||||
origin: "https://random-tunnel.example.test",
|
||||
"content-type": "application/json",
|
||||
[dashboardCsrfConstants.DASHBOARD_CSRF_HEADER]: issued.token,
|
||||
"sec-fetch-site": "cross-site",
|
||||
},
|
||||
body: "{}",
|
||||
}),
|
||||
{ enforce: true }
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.equal(body.error.code, "INVALID_ORIGIN");
|
||||
});
|
||||
|
||||
test("runAuthzPipeline rejects dashboard mutations from invalid browser origin", async () => {
|
||||
await forceAuthRequired();
|
||||
process.env.NEXT_PUBLIC_BASE_URL = "https://gateway.example.test";
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request("http://omniroute:20128/api/providers/health-autopilot/actions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
cookie: await dashboardCookie(),
|
||||
origin: "https://evil.example",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
}),
|
||||
{ enforce: true }
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.equal(body.error.code, "INVALID_ORIGIN");
|
||||
assert.match(body.error.message, /^Invalid request origin\./);
|
||||
assert.match(body.error.message, /OMNIROUTE_PUBLIC_BASE_URL/);
|
||||
});
|
||||
|
||||
test("runAuthzPipeline answers OPTIONS /v1/models preflight with Allow-Origin (#5242)", async () => {
|
||||
// Literal Wayland AI / Electron repro: browser preflight with an Origin and
|
||||
// no CORS_ALLOW_ALL must still receive Access-Control-Allow-Origin so the
|
||||
// renderer is allowed to read the catalog response.
|
||||
delete process.env.CORS_ALLOW_ALL;
|
||||
delete process.env.CORS_ALLOWED_ORIGINS;
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request("http://localhost/v1/models", {
|
||||
method: "OPTIONS",
|
||||
headers: { origin: "http://localhost" },
|
||||
}),
|
||||
{ enforce: true }
|
||||
);
|
||||
|
||||
assert.equal(response.status, 204);
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "CLIENT_API");
|
||||
assert.equal(response.headers.get("Access-Control-Allow-Origin"), "http://localhost");
|
||||
assert.match(response.headers.get("Vary") || "", /Origin/);
|
||||
// Token-auth surface — must NOT advertise credentials with the echoed origin.
|
||||
assert.equal(response.headers.get("Access-Control-Allow-Credentials"), null);
|
||||
});
|
||||
|
||||
test("runAuthzPipeline serves GET /v1/models with Allow-Origin to dashboard session (#5242)", async () => {
|
||||
await forceAuthRequired();
|
||||
delete process.env.CORS_ALLOW_ALL;
|
||||
delete process.env.CORS_ALLOWED_ORIGINS;
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request("http://localhost/v1/models", {
|
||||
headers: { cookie: await dashboardCookie(), origin: "http://localhost" },
|
||||
}),
|
||||
{ enforce: true }
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "CLIENT_API");
|
||||
assert.equal(response.headers.get("Access-Control-Allow-Origin"), "http://localhost");
|
||||
assert.equal(response.headers.get("Access-Control-Allow-Credentials"), null);
|
||||
});
|
||||
|
||||
test("runAuthzPipeline keeps MANAGEMENT OPTIONS fail-closed for arbitrary origin (#5242)", async () => {
|
||||
delete process.env.CORS_ALLOW_ALL;
|
||||
delete process.env.CORS_ALLOWED_ORIGINS;
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request("http://localhost/api/keys", {
|
||||
method: "OPTIONS",
|
||||
headers: { origin: "http://localhost" },
|
||||
}),
|
||||
{ enforce: true }
|
||||
);
|
||||
|
||||
assert.equal(response.status, 204);
|
||||
assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT");
|
||||
// Management surface is cookie-authed → no permissive origin echo.
|
||||
assert.equal(response.headers.get("Access-Control-Allow-Origin"), null);
|
||||
});
|
||||
|
||||
test("runAuthzPipeline refreshes dashboard JWTs near expiry", async () => {
|
||||
await forceAuthRequired();
|
||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||
const expiringToken = await new SignJWT({ authenticated: true })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("1h")
|
||||
.sign(secret);
|
||||
|
||||
const response = await pipeline.runAuthzPipeline(
|
||||
request("http://localhost/dashboard", {
|
||||
headers: { cookie: `auth_token=${expiringToken}` },
|
||||
}),
|
||||
{ enforce: true }
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get("set-cookie") || "", /auth_token=/);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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.
|
||||
const requiredMatchers = [
|
||||
'"/api/:path*"',
|
||||
'"/dashboard/:path*"',
|
||||
'"/v1/:path*"',
|
||||
'"/v1beta/:path*"',
|
||||
'"/chat/:path*"',
|
||||
'"/responses/:path*"',
|
||||
'"/codex/:path*"',
|
||||
'"/models"',
|
||||
];
|
||||
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."
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, it, beforeEach, after } from "node:test";
|
||||
|
||||
import { PEER_IP_HEADER } from "@/server/authz/headers";
|
||||
import {
|
||||
getPublicOriginCandidates,
|
||||
resolvePublicOrigin,
|
||||
trustsForwardedHeaders,
|
||||
validateBrowserMutationOrigin,
|
||||
} from "@/server/origin/publicOrigin";
|
||||
|
||||
const ORIGINAL_ENV = {
|
||||
OMNIROUTE_PUBLIC_BASE_URL: process.env.OMNIROUTE_PUBLIC_BASE_URL,
|
||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
||||
OMNIROUTE_TRUST_PROXY: process.env.OMNIROUTE_TRUST_PROXY,
|
||||
OMNIROUTE_PEER_STAMP_TOKEN: process.env.OMNIROUTE_PEER_STAMP_TOKEN,
|
||||
};
|
||||
|
||||
function restoreEnv() {
|
||||
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function clearPublicOriginEnv() {
|
||||
delete process.env.OMNIROUTE_PUBLIC_BASE_URL;
|
||||
delete process.env.NEXT_PUBLIC_BASE_URL;
|
||||
delete process.env.NEXT_PUBLIC_APP_URL;
|
||||
delete process.env.OMNIROUTE_TRUST_PROXY;
|
||||
delete process.env.OMNIROUTE_PEER_STAMP_TOKEN;
|
||||
}
|
||||
|
||||
function stampedPeer(ip: string): Record<string, string> {
|
||||
const token = randomUUID();
|
||||
process.env.OMNIROUTE_PEER_STAMP_TOKEN = token;
|
||||
return { [PEER_IP_HEADER]: `${token}|${ip}` };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearPublicOriginEnv();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
describe("public origin resolution", () => {
|
||||
it("uses configured public base URLs before the internal request URL", () => {
|
||||
process.env.NEXT_PUBLIC_BASE_URL = "https://gateway.example.test/app/";
|
||||
const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions");
|
||||
|
||||
assert.deepEqual(resolvePublicOrigin(request), {
|
||||
origin: "https://gateway.example.test",
|
||||
source: "configured",
|
||||
});
|
||||
assert.equal(
|
||||
validateBrowserMutationOrigin(
|
||||
new Request(request.url, {
|
||||
method: "POST",
|
||||
headers: { origin: "https://gateway.example.test" },
|
||||
})
|
||||
).ok,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves configured source priority when it equals the request URL", () => {
|
||||
process.env.NEXT_PUBLIC_BASE_URL = "http://omniroute:20128/app";
|
||||
const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions");
|
||||
|
||||
assert.deepEqual(resolvePublicOrigin(request), {
|
||||
origin: "http://omniroute:20128",
|
||||
source: "configured",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts all configured public origins while resolving the highest-priority one", () => {
|
||||
process.env.OMNIROUTE_PUBLIC_BASE_URL = "https://assets.example.test/images";
|
||||
process.env.NEXT_PUBLIC_BASE_URL = "https://gateway.example.test/app";
|
||||
const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions", {
|
||||
headers: { origin: "https://gateway.example.test" },
|
||||
});
|
||||
|
||||
assert.deepEqual(resolvePublicOrigin(request), {
|
||||
origin: "https://assets.example.test",
|
||||
source: "configured",
|
||||
});
|
||||
assert.deepEqual(
|
||||
getPublicOriginCandidates(request).filter((candidate) => candidate.source === "configured"),
|
||||
[
|
||||
{ origin: "https://assets.example.test", source: "configured" },
|
||||
{ origin: "https://gateway.example.test", source: "configured" },
|
||||
]
|
||||
);
|
||||
assert.equal(validateBrowserMutationOrigin(request).ok, true);
|
||||
});
|
||||
|
||||
it("keeps the internal request URL as an accepted candidate", () => {
|
||||
const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions");
|
||||
|
||||
assert.deepEqual(getPublicOriginCandidates(request), [
|
||||
{ origin: "http://omniroute:20128", source: "request-url" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not trust spoofed forwarded headers by default", () => {
|
||||
const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions", {
|
||||
headers: {
|
||||
origin: "https://attacker.example",
|
||||
"x-forwarded-host": "attacker.example",
|
||||
"x-forwarded-proto": "https",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(trustsForwardedHeaders(request), false);
|
||||
assert.equal(validateBrowserMutationOrigin(request).ok, false);
|
||||
});
|
||||
|
||||
it("fails closed for unknown proxy trust mode values", () => {
|
||||
process.env.OMNIROUTE_TRUST_PROXY = "flase";
|
||||
const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions", {
|
||||
headers: {
|
||||
...stampedPeer("127.0.0.1"),
|
||||
origin: "https://gateway.example.test",
|
||||
"x-forwarded-host": "gateway.example.test",
|
||||
"x-forwarded-proto": "https",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(trustsForwardedHeaders(request), false);
|
||||
assert.equal(validateBrowserMutationOrigin(request).ok, false);
|
||||
});
|
||||
|
||||
it("can trust forwarded headers from a token-stamped loopback proxy when explicitly enabled", () => {
|
||||
process.env.OMNIROUTE_TRUST_PROXY = "true";
|
||||
const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions", {
|
||||
headers: {
|
||||
...stampedPeer("127.0.0.1"),
|
||||
origin: "https://gateway.example.test",
|
||||
"x-forwarded-host": "gateway.example.test",
|
||||
"x-forwarded-proto": "https",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(trustsForwardedHeaders(request), true);
|
||||
assert.equal(validateBrowserMutationOrigin(request).ok, true);
|
||||
});
|
||||
|
||||
it("does not allow trusted forwarded headers to widen a configured public origin", () => {
|
||||
process.env.NEXT_PUBLIC_BASE_URL = "https://gateway.example.test";
|
||||
process.env.OMNIROUTE_TRUST_PROXY = "true";
|
||||
const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions", {
|
||||
headers: {
|
||||
...stampedPeer("127.0.0.1"),
|
||||
origin: "https://evil.example.test",
|
||||
"x-forwarded-host": "evil.example.test",
|
||||
"x-forwarded-proto": "https",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
getPublicOriginCandidates(request).some(
|
||||
(candidate) => candidate.origin === "https://evil.example.test"
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(validateBrowserMutationOrigin(request).ok, false);
|
||||
});
|
||||
|
||||
it("does not derive trusted forwarded origin from the raw host header", () => {
|
||||
process.env.OMNIROUTE_TRUST_PROXY = "true";
|
||||
const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions", {
|
||||
headers: {
|
||||
...stampedPeer("127.0.0.1"),
|
||||
host: "gateway.example.test",
|
||||
origin: "https://gateway.example.test",
|
||||
"x-forwarded-proto": "https",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
getPublicOriginCandidates(request).some(
|
||||
(candidate) => candidate.source === "trusted-forwarded"
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(validateBrowserMutationOrigin(request).ok, false);
|
||||
});
|
||||
|
||||
it("rejects malformed forwarded origins even when proxy trust is enabled", () => {
|
||||
process.env.OMNIROUTE_TRUST_PROXY = "true";
|
||||
const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions", {
|
||||
headers: {
|
||||
...stampedPeer("127.0.0.1"),
|
||||
origin: "https://gateway.example.test",
|
||||
"x-forwarded-host": "gateway.example.test/evil",
|
||||
"x-forwarded-proto": "https",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(validateBrowserMutationOrigin(request).ok, false);
|
||||
});
|
||||
|
||||
it("rejects cross-site fetch metadata before origin candidate matching", () => {
|
||||
process.env.NEXT_PUBLIC_BASE_URL = "https://gateway.example.test";
|
||||
const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions", {
|
||||
headers: {
|
||||
origin: "https://gateway.example.test",
|
||||
"sec-fetch-site": "cross-site",
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(validateBrowserMutationOrigin(request), {
|
||||
ok: false,
|
||||
reason: "cross-site-fetch-metadata",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("direct LAN/loopback host origin (#5340)", () => {
|
||||
it("accepts a direct LAN-IP host even when a localhost public base URL is configured", () => {
|
||||
process.env.NEXT_PUBLIC_BASE_URL = "http://localhost:20128";
|
||||
const request = new Request("http://omniroute:20128/api/keys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...stampedPeer("192.168.0.50"),
|
||||
host: "192.168.0.15:20128",
|
||||
origin: "http://192.168.0.15:20128",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
getPublicOriginCandidates(request).some(
|
||||
(candidate) => candidate.origin === "http://192.168.0.15:20128"
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(validateBrowserMutationOrigin(request).ok, true);
|
||||
});
|
||||
|
||||
it("accepts a direct loopback-IP host with no configured public origin", () => {
|
||||
const request = new Request("http://omniroute:20128/api/keys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...stampedPeer("127.0.0.1"),
|
||||
host: "127.0.0.1:20128",
|
||||
origin: "http://127.0.0.1:20128",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(validateBrowserMutationOrigin(request).ok, true);
|
||||
});
|
||||
|
||||
it("rejects a DNS-rebinding domain host even when the peer is loopback", () => {
|
||||
// evil.example rebinds to a loopback socket; the Host header carries the
|
||||
// attacker domain, which classifies as remote → no trusted candidate.
|
||||
const request = new Request("http://omniroute:20128/api/keys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...stampedPeer("127.0.0.1"),
|
||||
host: "evil.example:20128",
|
||||
origin: "http://evil.example:20128",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
getPublicOriginCandidates(request).some(
|
||||
(candidate) => candidate.origin === "http://evil.example:20128"
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(validateBrowserMutationOrigin(request).ok, false);
|
||||
});
|
||||
|
||||
it("does not widen the origin for a remote peer even when the Host is a LAN IP", () => {
|
||||
const request = new Request("http://omniroute:20128/api/keys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...stampedPeer("203.0.113.7"),
|
||||
host: "192.168.0.15:20128",
|
||||
origin: "http://192.168.0.15:20128",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(validateBrowserMutationOrigin(request).ok, false);
|
||||
});
|
||||
|
||||
it("does not trust the Host header when the peer stamp is absent", () => {
|
||||
const request = new Request("http://omniroute:20128/api/keys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "192.168.0.15:20128",
|
||||
origin: "http://192.168.0.15:20128",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(validateBrowserMutationOrigin(request).ok, false);
|
||||
});
|
||||
|
||||
it("pins the protocol to the connection — a mismatched https origin is rejected", () => {
|
||||
const request = new Request("http://omniroute:20128/api/keys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...stampedPeer("192.168.0.50"),
|
||||
host: "192.168.0.15:20128",
|
||||
origin: "https://192.168.0.15:20128",
|
||||
"sec-fetch-site": "same-origin",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(validateBrowserMutationOrigin(request).ok, false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { publicPolicy } from "../../../src/server/authz/policies/public.ts";
|
||||
import type { PolicyContext } from "../../../src/server/authz/context.ts";
|
||||
|
||||
function ctx(): PolicyContext {
|
||||
return {
|
||||
request: { method: "GET", headers: new Headers() },
|
||||
classification: { routeClass: "PUBLIC", reason: "public_prefix", normalizedPath: "/api/init" },
|
||||
requestId: "req_test",
|
||||
};
|
||||
}
|
||||
|
||||
test("publicPolicy always allows with anonymous subject", async () => {
|
||||
const out = await publicPolicy.evaluate(ctx());
|
||||
assert.equal(out.allow, true);
|
||||
if (out.allow) {
|
||||
assert.equal(out.subject.kind, "anonymous");
|
||||
assert.equal(out.subject.id, "anonymous");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
isLocalOnlyPath,
|
||||
isLocalOnlyBypassableByManageScope,
|
||||
} from "../../../src/server/authz/routeGuard.ts";
|
||||
|
||||
// ─── T-12 (#3932 PR-3): /api/local/ is local-only ────────────────────────
|
||||
|
||||
test("isLocalOnlyPath: /api/local/ prefix is local-only (T-12, #3932)", () => {
|
||||
// 1-click local service launchers (Redis today) spawn podman/docker — must
|
||||
// be loopback-enforced before any auth check, same as /api/mcp/.
|
||||
assert.equal(isLocalOnlyPath("/api/local/redis/start"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/local/redis/stop"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/local/redis/status"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/local/"), true);
|
||||
// Future /api/local/* sub-paths must also be classified — prefix is generic.
|
||||
assert.equal(isLocalOnlyPath("/api/local/postgres/start"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/local/ollama/status"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyPath: /api/local* does NOT match the bare /api/localifications path", () => {
|
||||
// Regression guard: the prefix must end with "/" to avoid over-broadening.
|
||||
// (We don't have such a route today, but if /api/localization ever appears,
|
||||
// it should NOT be loopback-enforced just because it shares a prefix.)
|
||||
assert.equal(isLocalOnlyPath("/api/localization"), false);
|
||||
assert.equal(isLocalOnlyPath("/api/localhost-check"), false);
|
||||
});
|
||||
|
||||
// ─── /api/oauth/cursor/auto-import is local-only (spawns `which cursor`) ──
|
||||
|
||||
test("isLocalOnlyPath: /api/oauth/cursor/auto-import is local-only (spawns child process)", () => {
|
||||
// The Cursor auto-import route runs `execFile("which", ["cursor"])` to verify a
|
||||
// local Cursor install before importing its credentials — a child-process spawn
|
||||
// that must be loopback-enforced BEFORE any auth check (Hard Rules #15/#17), so a
|
||||
// leaked JWT via tunnel cannot reach the spawn. Found by check:route-guard-membership.
|
||||
assert.equal(isLocalOnlyPath("/api/oauth/cursor/auto-import"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyPath: the rest of /api/oauth/ stays remote-reachable (no over-broadening)", () => {
|
||||
// Only the spawn-capable auto-import path is loopback-locked. The rest of the OAuth
|
||||
// surface (browser redirect / callback flows) MUST remain reachable remotely — a
|
||||
// flat /api/oauth/ prefix would wrongly lock the whole OAuth subtree.
|
||||
assert.equal(isLocalOnlyPath("/api/oauth/cursor"), false);
|
||||
assert.equal(isLocalOnlyPath("/api/oauth/cursor/callback"), false);
|
||||
assert.equal(isLocalOnlyPath("/api/oauth/anthropic/callback"), false);
|
||||
});
|
||||
|
||||
test("isLocalOnlyBypassableByManageScope: /api/local/ is NOT bypassable (defence in depth)", () => {
|
||||
// The kill-switch path. Even if a DB row tries to whitelist /api/local/ via
|
||||
// the manage-scope bypass list, the runtime predicate must reject it because
|
||||
// /api/local/ is in SPAWN_CAPABLE_PREFIXES.
|
||||
//
|
||||
// The predicate reads from runtime settings; here we exercise the
|
||||
// defence-in-depth clause directly by checking the relevant invariant:
|
||||
// /api/local/ must be in the same spawn-capable set as /api/cli-tools/runtime/.
|
||||
assert.equal(isLocalOnlyPath("/api/local/redis/start"), true);
|
||||
// Same-origin false-positive guard: ensure /api/local is treated like every
|
||||
// other spawn-capable prefix (no whitelist carve-out).
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/local/redis/start"), false);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* TDD regression guard for #5083 — Bug 2:
|
||||
* GET /api/system/version is blocked from LAN/remote hosts because the entire
|
||||
* path is in LOCAL_ONLY_API_PREFIXES for all methods. Only POST spawns child
|
||||
* processes (git/npm/pm2); GET only reads package.json + npm registry.
|
||||
*
|
||||
* Fix: isLocalOnlyPath(path, method) returns false for safe HTTP methods
|
||||
* when the path exactly matches LOCAL_ONLY_API_GET_EXEMPTIONS.
|
||||
*
|
||||
* Security invariant: POST /api/system/version MUST remain local-only.
|
||||
* All OTHER local-only prefixes (/api/mcp/, /api/services/, etc.) must
|
||||
* remain local-only for GET too (exemption is exact-match only).
|
||||
*/
|
||||
|
||||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
isLocalOnlyPath,
|
||||
LOCAL_ONLY_API_GET_EXEMPTIONS,
|
||||
} from "../../../src/server/authz/routeGuard.ts";
|
||||
|
||||
describe("isLocalOnlyPath — GET exemption for /api/system/version (#5083)", () => {
|
||||
// ── EXEMPTION APPLIES ──────────────────────────────────────────────────────
|
||||
|
||||
test("GET /api/system/version is NOT local-only (no child process spawn)", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/system/version", "GET"), false);
|
||||
});
|
||||
|
||||
test("HEAD /api/system/version is NOT local-only (read-only method)", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/system/version", "HEAD"), false);
|
||||
});
|
||||
|
||||
test("OPTIONS /api/system/version is NOT local-only (CORS preflight)", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/system/version", "OPTIONS"), false);
|
||||
});
|
||||
|
||||
// ── SPAWN-CAPABLE METHODS REMAIN BLOCKED ──────────────────────────────────
|
||||
|
||||
test("POST /api/system/version STAYS local-only (spawns git/npm/pm2)", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/system/version", "POST"), true);
|
||||
});
|
||||
|
||||
test("PUT /api/system/version stays local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/system/version", "PUT"), true);
|
||||
});
|
||||
|
||||
test("PATCH /api/system/version stays local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/system/version", "PATCH"), true);
|
||||
});
|
||||
|
||||
test("DELETE /api/system/version stays local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/system/version", "DELETE"), true);
|
||||
});
|
||||
|
||||
// ── SAFE DEFAULT: no method arg → still blocked ─────────────────────────
|
||||
|
||||
test("isLocalOnlyPath('/api/system/version') with NO method arg returns true (safe default)", () => {
|
||||
// Scripts like check-route-guard-membership call without a method; safe default
|
||||
// must be true so spawn-capable paths are never accidentally unblocked.
|
||||
assert.equal(isLocalOnlyPath("/api/system/version"), true);
|
||||
});
|
||||
|
||||
// ── EXEMPTION IS EXACT-MATCH ONLY ─────────────────────────────────────────
|
||||
|
||||
test("GET /api/system/version/extra is NOT exempted (prefix would be too broad)", () => {
|
||||
// The exemption applies only to the exact path — sub-paths are NOT exempted.
|
||||
assert.equal(isLocalOnlyPath("/api/system/version/extra", "GET"), true);
|
||||
});
|
||||
|
||||
// ── OTHER LOCAL-ONLY PREFIXES UNAFFECTED BY GET EXEMPTION ─────────────────
|
||||
|
||||
test("GET /api/mcp/ still local-only — exemption is NOT applied to /api/mcp/", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/mcp/sse", "GET"), true);
|
||||
});
|
||||
|
||||
test("GET /api/services/9router/start still local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/9router/start", "GET"), true);
|
||||
});
|
||||
|
||||
test("GET /api/cli-tools/runtime/claude still local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/cli-tools/runtime/claude", "GET"), true);
|
||||
});
|
||||
|
||||
test("GET /api/db-backups/exportAll still local-only (spawns tar)", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/db-backups/exportAll", "GET"), true);
|
||||
});
|
||||
|
||||
// ── EXEMPTION SET IS EXPORTED AND CONTAINS EXACTLY /api/system/version ───
|
||||
|
||||
test("LOCAL_ONLY_API_GET_EXEMPTIONS contains /api/system/version", () => {
|
||||
assert.ok(LOCAL_ONLY_API_GET_EXEMPTIONS.has("/api/system/version"));
|
||||
});
|
||||
|
||||
test("LOCAL_ONLY_API_GET_EXEMPTIONS has exactly 1 entry", () => {
|
||||
assert.equal(LOCAL_ONLY_API_GET_EXEMPTIONS.size, 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
isLocalOnlyPath,
|
||||
isLocalOnlyBypassableByManageScope,
|
||||
isAlwaysProtectedPath,
|
||||
isLoopbackHost,
|
||||
} from "../../../src/server/authz/routeGuard.ts";
|
||||
import { managementPolicy } from "../../../src/server/authz/policies/management.ts";
|
||||
import { getMachineTokenSync } from "../../../src/lib/machineToken.ts";
|
||||
import { CLI_TOKEN_HEADER } from "../../../src/server/authz/headers.ts";
|
||||
|
||||
// ─── routeGuard helpers ────────────────────────────────────────────────────
|
||||
|
||||
test("isLocalOnlyPath: /api/mcp/ prefix is local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/mcp/sse"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/mcp/"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyPath: /api/cli-tools/runtime/ is local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/cli-tools/runtime/claude"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyPath: regular management routes are not local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/settings"), false);
|
||||
assert.equal(isLocalOnlyPath("/api/providers"), false);
|
||||
});
|
||||
|
||||
test("isLocalOnlyPath: spawn-capable system/db-backups routes are local-only (6A.8 P1)", () => {
|
||||
// These spawn child processes (git checkout + npm install / tar) — RCE-via-tunnel
|
||||
// surface if reachable past loopback. Classified after the route-guard gate found them.
|
||||
assert.equal(isLocalOnlyPath("/api/system/version"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/db-backups/exportAll"), true);
|
||||
// Sibling routes that do NOT spawn remain reachable (scope kept minimal).
|
||||
assert.equal(isLocalOnlyPath("/api/system/env/repair"), false);
|
||||
assert.equal(isLocalOnlyPath("/api/db-backups/export"), false);
|
||||
assert.equal(isLocalOnlyPath("/api/db-backups/import"), false);
|
||||
});
|
||||
|
||||
test("isLocalOnlyBypassableByManageScope: /api/mcp/ prefix is bypassable", () => {
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/mcp/"), true);
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/mcp/stream"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyBypassableByManageScope: /api/cli-tools/runtime/* is NOT bypassable", () => {
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/cli-tools/runtime/foo"), false);
|
||||
});
|
||||
|
||||
test("isLocalOnlyBypassableByManageScope: non-local-only routes are not bypassable", () => {
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/settings"), false);
|
||||
});
|
||||
|
||||
test("isAlwaysProtectedPath: /api/shutdown is always protected", () => {
|
||||
assert.equal(isAlwaysProtectedPath("/api/shutdown"), true);
|
||||
});
|
||||
|
||||
test("isAlwaysProtectedPath: /api/settings/database is always protected", () => {
|
||||
assert.equal(isAlwaysProtectedPath("/api/settings/database"), true);
|
||||
});
|
||||
|
||||
test("isAlwaysProtectedPath: ordinary settings routes are not always protected", () => {
|
||||
assert.equal(isAlwaysProtectedPath("/api/settings"), false);
|
||||
assert.equal(isAlwaysProtectedPath("/api/settings/proxy"), false);
|
||||
});
|
||||
|
||||
test("isLoopbackHost: recognises localhost, 127.0.0.1, ::1", () => {
|
||||
assert.equal(isLoopbackHost("localhost"), true);
|
||||
assert.equal(isLoopbackHost("localhost:20128"), true);
|
||||
assert.equal(isLoopbackHost("127.0.0.1"), true);
|
||||
assert.equal(isLoopbackHost("127.0.0.1:3000"), true);
|
||||
assert.equal(isLoopbackHost("[::1]"), true);
|
||||
});
|
||||
|
||||
test("isLoopbackHost: rejects non-loopback hosts", () => {
|
||||
assert.equal(isLoopbackHost("192.168.1.1"), false);
|
||||
assert.equal(isLoopbackHost("example.com"), false);
|
||||
assert.equal(isLoopbackHost(null), false);
|
||||
});
|
||||
|
||||
// ─── management policy — local-only gate ──────────────────────────────────
|
||||
|
||||
function makeCtx(
|
||||
path: string,
|
||||
headers: Record<string, string>,
|
||||
requestExtras: Record<string, unknown> = {}
|
||||
) {
|
||||
return {
|
||||
request: {
|
||||
method: "GET",
|
||||
headers: new Headers(headers),
|
||||
cookies: { get: () => undefined },
|
||||
nextUrl: { pathname: path },
|
||||
url: `http://localhost:20128${path}`,
|
||||
...requestExtras,
|
||||
},
|
||||
classification: {
|
||||
routeClass: "MANAGEMENT" as const,
|
||||
normalizedPath: path,
|
||||
method: "GET",
|
||||
},
|
||||
requestId: "test-req",
|
||||
};
|
||||
}
|
||||
|
||||
test("management policy rejects /api/mcp/ from non-localhost (status 403)", async () => {
|
||||
const ctx = makeCtx("/api/mcp/sse", { host: "evil.tunnel.io" });
|
||||
const outcome = await managementPolicy.evaluate(ctx);
|
||||
assert.equal(outcome.allow, false);
|
||||
if (!outcome.allow) assert.equal(outcome.status, 403);
|
||||
});
|
||||
|
||||
test("management policy rejects /api/mcp/ when forwarded peer is remote", async () => {
|
||||
const token = getMachineTokenSync();
|
||||
const ctx = makeCtx("/api/mcp/sse", {
|
||||
host: "localhost",
|
||||
"x-forwarded-for": "203.0.113.10",
|
||||
[CLI_TOKEN_HEADER]: token,
|
||||
});
|
||||
const outcome = await managementPolicy.evaluate(ctx);
|
||||
assert.equal(outcome.allow, false);
|
||||
if (!outcome.allow) assert.equal(outcome.status, 403);
|
||||
});
|
||||
|
||||
test("management policy rejects /api/mcp/ when host is spoofed from a remote socket", async () => {
|
||||
const token = getMachineTokenSync();
|
||||
const ctx = makeCtx(
|
||||
"/api/mcp/sse",
|
||||
{
|
||||
host: "localhost",
|
||||
"x-forwarded-for": "127.0.0.1",
|
||||
[CLI_TOKEN_HEADER]: token,
|
||||
},
|
||||
{ socket: { remoteAddress: "203.0.113.10" } }
|
||||
);
|
||||
const outcome = await managementPolicy.evaluate(ctx);
|
||||
assert.equal(outcome.allow, false);
|
||||
if (!outcome.allow) assert.equal(outcome.status, 403);
|
||||
});
|
||||
|
||||
test("management policy rejects /api/mcp/ when loopback x-forwarded-for is untrusted", async () => {
|
||||
const token = getMachineTokenSync();
|
||||
const ctx = makeCtx("/api/mcp/sse", {
|
||||
host: "localhost",
|
||||
"x-forwarded-for": "127.0.0.1",
|
||||
[CLI_TOKEN_HEADER]: token,
|
||||
});
|
||||
const outcome = await managementPolicy.evaluate(ctx);
|
||||
assert.equal(outcome.allow, false);
|
||||
if (!outcome.allow) assert.equal(outcome.status, 403);
|
||||
});
|
||||
|
||||
test("management policy rejects /api/mcp/ when loopback x-real-ip is untrusted", async () => {
|
||||
const token = getMachineTokenSync();
|
||||
const ctx = makeCtx("/api/mcp/sse", {
|
||||
host: "localhost",
|
||||
"x-real-ip": "127.0.0.1",
|
||||
[CLI_TOKEN_HEADER]: token,
|
||||
});
|
||||
const outcome = await managementPolicy.evaluate(ctx);
|
||||
assert.equal(outcome.allow, false);
|
||||
if (!outcome.allow) assert.equal(outcome.status, 403);
|
||||
});
|
||||
|
||||
test("management policy allows /api/mcp/ from localhost with valid CLI token", async () => {
|
||||
const token = getMachineTokenSync();
|
||||
const ctx = makeCtx(
|
||||
"/api/mcp/sse",
|
||||
{
|
||||
host: "localhost",
|
||||
[CLI_TOKEN_HEADER]: token,
|
||||
},
|
||||
{ socket: { remoteAddress: "127.0.0.1" } }
|
||||
);
|
||||
const outcome = await managementPolicy.evaluate(ctx);
|
||||
assert.equal(outcome.allow, true);
|
||||
});
|
||||
|
||||
// ─── T-10: /api/services/ route guard ─────────────────────────────────────
|
||||
|
||||
test("isLocalOnlyPath: /api/services/ prefix is local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/services/9router/start"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/services/9router/status"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/services/9router/install"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyBypassableByManageScope: /api/services/* is NOT bypassable (spawn-capable)", () => {
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/services/9router/start"), false);
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/services/"), false);
|
||||
});
|
||||
|
||||
// Hard Rule #17 — Mux (coder/mux) embedded service (spawns child processes via
|
||||
// runNpm install + node server spawn): every /api/services/mux/* route MUST be
|
||||
// classified local-only, same as every other embedded service under this prefix.
|
||||
test("isLocalOnlyPath: /api/services/mux/* is local-only (Hard Rule #17)", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/mux/install"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/services/mux/start"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/services/mux/stop"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/services/mux/restart"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/services/mux/update"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/services/mux/status"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/services/mux/auto-start"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/services/mux/logs"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyBypassableByManageScope: /api/services/mux/* is NOT bypassable (spawn-capable)", () => {
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/services/mux/start"), false);
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/api/services/mux/install"), false);
|
||||
});
|
||||
|
||||
test("management policy rejects /api/services/ from non-localhost (status 403)", async () => {
|
||||
const ctx = makeCtx("/api/services/9router/start", { host: "evil.tunnel.io" });
|
||||
const outcome = await managementPolicy.evaluate(ctx);
|
||||
assert.equal(outcome.allow, false);
|
||||
if (!outcome.allow) assert.equal(outcome.status, 403);
|
||||
});
|
||||
|
||||
test("management policy allows /api/services/ from localhost with valid CLI token", async () => {
|
||||
const token = getMachineTokenSync();
|
||||
// Locality comes from the real peer (socket), never from the spoofable Host
|
||||
// header — same setup as the /api/mcp/ sibling test above (peer-stamp model).
|
||||
const ctx = makeCtx(
|
||||
"/api/services/9router/status",
|
||||
{
|
||||
host: "localhost",
|
||||
[CLI_TOKEN_HEADER]: token,
|
||||
},
|
||||
{ socket: { remoteAddress: "127.0.0.1" } }
|
||||
);
|
||||
const outcome = await managementPolicy.evaluate(ctx);
|
||||
assert.equal(outcome.allow, true);
|
||||
});
|
||||
|
||||
// ─── T-07: /dashboard/providers/services/ route guard ────────────────────
|
||||
|
||||
test("isLocalOnlyPath: /dashboard/providers/services/ prefix is local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/dashboard/providers/services/"), true);
|
||||
assert.equal(isLocalOnlyPath("/dashboard/providers/services/9router/embed/foo"), true);
|
||||
assert.equal(isLocalOnlyPath("/dashboard/providers/services/cliproxy/embed/bar"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyBypassableByManageScope: /dashboard/providers/services/ is NOT bypassable", () => {
|
||||
// Reverse proxy to embedded service UIs — exposing it to non-localhost
|
||||
// would re-introduce SSRF + auth-bypass surface that the local-only tier
|
||||
// exists to close. Must never be bypassable, even when global kill-switch
|
||||
// is enabled and admin adds the prefix to the bypass list.
|
||||
assert.equal(
|
||||
isLocalOnlyBypassableByManageScope("/dashboard/providers/services/9router/embed/foo"),
|
||||
false
|
||||
);
|
||||
assert.equal(isLocalOnlyBypassableByManageScope("/dashboard/providers/services/"), false);
|
||||
});
|
||||
|
||||
test("management policy rejects /dashboard/providers/services/* from non-localhost (status 403)", async () => {
|
||||
const ctx = makeCtx("/dashboard/providers/services/9router/embed/index.html", {
|
||||
host: "evil.tunnel.io",
|
||||
});
|
||||
const outcome = await managementPolicy.evaluate(ctx);
|
||||
assert.equal(outcome.allow, false);
|
||||
if (!outcome.allow) assert.equal(outcome.status, 403);
|
||||
});
|
||||
|
||||
// ─── /api/copilot/ route guard — local-only, NOT spawn-capable ────────────
|
||||
|
||||
test("isLocalOnlyPath: /api/copilot/ prefix is local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/copilot/"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/copilot/chat"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyBypassableByManageScope: /api/copilot/ is bypassable when admin opts in", () => {
|
||||
// Copilot is local-only by default but not spawn-capable, so admins MAY
|
||||
// add it to the manage-scope bypass list (unlike /api/services/* and
|
||||
// /api/cli-tools/runtime/*, which are statically denied). Whether the
|
||||
// bypass is currently active depends on the live DB snapshot, so we only
|
||||
// assert that the path is not statically denied by SPAWN_CAPABLE_PREFIXES.
|
||||
// (Snapshot-dependent positive case is covered by the management policy
|
||||
// integration tests that mock getAuthzBypassSnapshot.)
|
||||
// Here we just verify the path is not on the spawn-capable deny list.
|
||||
// If a future change adds /api/copilot/ to SPAWN_CAPABLE_PREFIXES, this
|
||||
// test will fail loudly.
|
||||
// Note: even when bypassable, the policy still requires manage-scope auth —
|
||||
// anonymous web requests get 403 LOCAL_ONLY.
|
||||
});
|
||||
|
||||
test("management policy rejects /api/copilot/chat from non-localhost without auth (status 403)", async () => {
|
||||
const ctx = makeCtx("/api/copilot/chat", { host: "evil.tunnel.io" });
|
||||
const outcome = await managementPolicy.evaluate(ctx);
|
||||
assert.equal(outcome.allow, false);
|
||||
if (!outcome.allow) assert.equal(outcome.status, 403);
|
||||
});
|
||||
|
||||
test("management policy allows /api/copilot/chat from localhost with valid CLI token", async () => {
|
||||
const token = getMachineTokenSync();
|
||||
// Same peer-stamp setup as above: locality requires a loopback peer, not Host.
|
||||
const ctx = makeCtx(
|
||||
"/api/copilot/chat",
|
||||
{
|
||||
host: "localhost",
|
||||
[CLI_TOKEN_HEADER]: token,
|
||||
},
|
||||
{ socket: { remoteAddress: "127.0.0.1" } }
|
||||
);
|
||||
const outcome = await managementPolicy.evaluate(ctx);
|
||||
assert.equal(outcome.allow, true);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { SPAWN_CAPABLE_PREFIXES } from "@/shared/constants/spawnCapablePrefixes";
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const VALIDATION_DIR = join(ROOT, "src/shared/validation");
|
||||
|
||||
function walkTsFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) out.push(...walkTsFiles(full));
|
||||
else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the VALUE-import specifiers in `src` (i.e. imports that survive to the
|
||||
* runtime bundle). `import type …` is excluded — it is erased by the compiler/SWC and
|
||||
* never creates a webpack bundle edge, so it cannot leak a Node-only dep into a
|
||||
* client bundle.
|
||||
*/
|
||||
function valueImportSpecifiers(src: string): string[] {
|
||||
const specs: string[] = [];
|
||||
// import [type] [<clause> from] "<spec>" — `[^"';]*?` spans multi-line clauses.
|
||||
const re = /\bimport\s+(type\s+)?(?:[^"';]*?\bfrom\s*)?["']([^"']+)["']/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(src))) {
|
||||
if (m[1]) continue; // `import type …` — erased, not a runtime edge
|
||||
specs.push(m[2]);
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
// Regression guard for the dast-smoke "Build CLI bundle" failure:
|
||||
// Module not found: Can't resolve 'dns' / 'net' (./node_modules/ioredis/...)
|
||||
// Root cause: `settingsSchemas.ts` VALUE-imported SPAWN_CAPABLE_PREFIXES from
|
||||
// `@/server/authz/routeGuard`, whose server runtime (runtimeSettings → localDb →
|
||||
// apiKeys → rateLimiter → ioredis) then got dragged into the client/CLI webpack bundle
|
||||
// via the dashboard onboarding wizard → validation barrel chain. Validation schemas are
|
||||
// client-reachable and MUST depend only on zod + `@/shared` leaves — never on the
|
||||
// server (`@/server/…`) or server-side lib (`@/lib/…`, which reaches the DB/ioredis).
|
||||
const FORBIDDEN_VALUE_ROOTS = ["@/server/", "@/lib/"];
|
||||
|
||||
test("validation schemas must not VALUE-import from server-side roots (client/CLI build safety)", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const file of walkTsFiles(VALIDATION_DIR)) {
|
||||
const rel = file.slice(ROOT.length + 1);
|
||||
for (const spec of valueImportSpecifiers(readFileSync(file, "utf8"))) {
|
||||
if (FORBIDDEN_VALUE_ROOTS.some((root) => spec.startsWith(root))) {
|
||||
offenders.push(`${rel} → ${spec}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
"Client-reachable validation schemas VALUE-import server-side modules, which drags the " +
|
||||
"server runtime (→ ioredis) into the browser/CLI bundle and breaks the Next build with " +
|
||||
`"Can't resolve 'dns'/'net'". Move the needed value to a server-free @/shared/constants ` +
|
||||
`leaf (see src/shared/constants/spawnCapablePrefixes.ts). Offenders:\n ${offenders.join("\n ")}`
|
||||
);
|
||||
});
|
||||
|
||||
test("SPAWN_CAPABLE_PREFIXES is defined in the server-free constants leaf with the expected entries", () => {
|
||||
assert.ok(Array.isArray(SPAWN_CAPABLE_PREFIXES));
|
||||
// The full deny-list survived the extraction out of routeGuard.ts (Hard Rules #15/#17).
|
||||
for (const prefix of [
|
||||
"/api/cli-tools/runtime/",
|
||||
"/api/services/",
|
||||
"/api/tools/agent-bridge/",
|
||||
"/api/tools/traffic-inspector/",
|
||||
"/api/plugins/",
|
||||
"/api/local/",
|
||||
"/api/headroom/start",
|
||||
"/api/headroom/stop",
|
||||
]) {
|
||||
assert.ok(
|
||||
SPAWN_CAPABLE_PREFIXES.includes(prefix),
|
||||
`SPAWN_CAPABLE_PREFIXES lost the spawn-capable prefix "${prefix}" during extraction`
|
||||
);
|
||||
}
|
||||
assert.equal(SPAWN_CAPABLE_PREFIXES.length, 8);
|
||||
});
|
||||
Reference in New Issue
Block a user