security/platform: close audit findings #19–#26
Tests, CSP nonce middleware, SSRF guard, perf-route hardening, Docker env isolation, migration runbook, RBAC E2E coverage. Tickets resolved: - #19: MfaSetup.test.ts — static source tests confirming local QR rendering - #20: ssrf-guard.test.ts (16 tests) + webhook-procedure-support mock fix - #21: /api/perf route.test.ts (5 tests) — header-only auth, fail-closed - #22: middleware.ts (nonce-based CSP) + middleware.test.ts (6 tests); layout.tsx async + nonce prop; CSP removed from next.config.ts - #23: Active-session registry enforcement verified (already in codebase) - #24: docker-compose.yml REDIS_URL hardcoded (no host-env substitution) - #25: docker-compose.yml REDIS_URL + docs/developer-runbook.md created - #26: e2e/dev-system/rbac-data-access.spec.ts (12 tests, 3 roles × 4 procedures) Quality gates: tsc clean, api 1447/1447, web 189/189 passing. Turbo concurrency capped at 2 (package.json) to prevent OOM under parallel test runs. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@capakraken/api/sse", () => ({
|
||||
eventBus: { subscriberCount: 0 },
|
||||
}));
|
||||
|
||||
// Lazy import so we can stub env before the module-level code runs.
|
||||
const importRoute = () => import("./route.js");
|
||||
|
||||
describe("GET /api/perf — security hardening", () => {
|
||||
const ORIGINAL_SECRET = process.env["CRON_SECRET"];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_SECRET === undefined) {
|
||||
delete process.env["CRON_SECRET"];
|
||||
} else {
|
||||
process.env["CRON_SECRET"] = ORIGINAL_SECRET;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 200 with metrics for an authorised request via Authorization header", async () => {
|
||||
process.env["CRON_SECRET"] = "test-secret-abc";
|
||||
const { GET } = await importRoute();
|
||||
|
||||
const request = new Request("http://localhost/api/perf", {
|
||||
headers: { Authorization: "Bearer test-secret-abc" },
|
||||
});
|
||||
|
||||
const response = await GET(request);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = await response.json() as { timestamp: string; uptime: unknown; memory: unknown };
|
||||
expect(typeof body.timestamp).toBe("string");
|
||||
expect(body.uptime).toBeDefined();
|
||||
expect(body.memory).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns 401 when no Authorization header is provided", async () => {
|
||||
process.env["CRON_SECRET"] = "test-secret-abc";
|
||||
const { GET } = await importRoute();
|
||||
|
||||
const request = new Request("http://localhost/api/perf");
|
||||
const response = await GET(request);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 401 when the Authorization header contains a wrong secret", async () => {
|
||||
process.env["CRON_SECRET"] = "test-secret-abc";
|
||||
const { GET } = await importRoute();
|
||||
|
||||
const request = new Request("http://localhost/api/perf", {
|
||||
headers: { Authorization: "Bearer wrong-secret" },
|
||||
});
|
||||
const response = await GET(request);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 401 for a query-param token — query-string auth is not supported", async () => {
|
||||
process.env["CRON_SECRET"] = "test-secret-abc";
|
||||
const { GET } = await importRoute();
|
||||
|
||||
// Pass secret as query param only — no Authorization header
|
||||
const request = new Request("http://localhost/api/perf?token=test-secret-abc");
|
||||
const response = await GET(request);
|
||||
// The endpoint ignores query params entirely; without a valid header it must reject.
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 401 and leaks no metrics when CRON_SECRET is not configured (fail-closed)", async () => {
|
||||
delete process.env["CRON_SECRET"];
|
||||
const { GET } = await importRoute();
|
||||
|
||||
// Even a request that would otherwise be valid must be rejected.
|
||||
const request = new Request("http://localhost/api/perf", {
|
||||
headers: { Authorization: "Bearer anything" },
|
||||
});
|
||||
const response = await GET(request);
|
||||
expect(response.status).toBe(401);
|
||||
|
||||
const body = await response.json() as { error?: string; timestamp?: string; memory?: unknown };
|
||||
expect(body.timestamp).toBeUndefined();
|
||||
expect(body.memory).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,9 @@ export const runtime = "nodejs";
|
||||
/**
|
||||
* GET /api/perf — Runtime performance metrics.
|
||||
*
|
||||
* Protected by CRON_SECRET header or query param.
|
||||
* Protected by CRON_SECRET via `Authorization: Bearer <secret>` header only.
|
||||
* Query-string authentication is not supported (secrets must not appear in URLs).
|
||||
* Fails closed (401) when CRON_SECRET is not configured in the environment.
|
||||
* Returns Node.js memory usage, process uptime, and SSE connection count.
|
||||
*/
|
||||
export function GET(request: Request) {
|
||||
|
||||
Reference in New Issue
Block a user