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,129 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { assertWebhookUrlAllowed } from "../lib/ssrf-guard.js";
|
||||
|
||||
// Mock dns.lookup so tests do not require real DNS resolution.
|
||||
vi.mock("node:dns/promises", () => ({
|
||||
lookup: vi.fn(async (hostname: string) => {
|
||||
const mapping: Record<string, string> = {
|
||||
"example.com": "93.184.216.34",
|
||||
"hooks.external.io": "52.1.2.3",
|
||||
};
|
||||
const ip = mapping[hostname];
|
||||
if (!ip) throw new Error(`ENOTFOUND ${hostname}`);
|
||||
return { address: ip, family: 4 };
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("assertWebhookUrlAllowed — SSRF guard", () => {
|
||||
// ── Allowed targets ─────────────────────────────────────────────────────────
|
||||
|
||||
it("allows a valid HTTPS URL that resolves to a public IP", async () => {
|
||||
await expect(
|
||||
assertWebhookUrlAllowed("https://example.com/webhook"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows an HTTPS URL with a path and query string", async () => {
|
||||
await expect(
|
||||
assertWebhookUrlAllowed("https://hooks.external.io/events?source=capakraken"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Rejected schemes ─────────────────────────────────────────────────────────
|
||||
|
||||
it("rejects an HTTP URL (only HTTPS allowed)", async () => {
|
||||
await expect(
|
||||
assertWebhookUrlAllowed("http://example.com/webhook"),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("rejects an FTP URL", async () => {
|
||||
await expect(
|
||||
assertWebhookUrlAllowed("ftp://example.com/file"),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("rejects a completely invalid URL", async () => {
|
||||
await expect(
|
||||
assertWebhookUrlAllowed("not-a-url"),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
// ── Blocked hostnames ────────────────────────────────────────────────────────
|
||||
|
||||
it("rejects localhost by hostname", async () => {
|
||||
await expect(
|
||||
assertWebhookUrlAllowed("https://localhost/callback"),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("rejects the AWS cloud metadata endpoint by hostname", async () => {
|
||||
await expect(
|
||||
assertWebhookUrlAllowed("https://169.254.169.254/latest/meta-data"),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("rejects Google cloud metadata by hostname", async () => {
|
||||
await expect(
|
||||
assertWebhookUrlAllowed("https://metadata.google.internal/computeMetadata/v1"),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
// ── Blocked IP ranges (direct IP addresses as hostname) ─────────────────────
|
||||
|
||||
it("rejects IPv4 loopback 127.0.0.1", async () => {
|
||||
await expect(
|
||||
assertWebhookUrlAllowed("https://127.0.0.1/callback"),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("rejects IPv4 loopback 127.1.2.3 (full /8 block)", async () => {
|
||||
await expect(
|
||||
assertWebhookUrlAllowed("https://127.1.2.3/callback"),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("rejects RFC 1918 private address 10.0.0.1", async () => {
|
||||
await expect(
|
||||
assertWebhookUrlAllowed("https://10.0.0.1/callback"),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("rejects RFC 1918 private address 172.16.0.1", async () => {
|
||||
await expect(
|
||||
assertWebhookUrlAllowed("https://172.16.0.1/callback"),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("rejects RFC 1918 private address 192.168.1.100", async () => {
|
||||
await expect(
|
||||
assertWebhookUrlAllowed("https://192.168.1.100/callback"),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("rejects link-local address 169.254.1.1", async () => {
|
||||
await expect(
|
||||
assertWebhookUrlAllowed("https://169.254.1.1/callback"),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
// ── DNS fail-closed behaviour ────────────────────────────────────────────────
|
||||
|
||||
it("rejects a hostname that cannot be resolved (fail-closed)", async () => {
|
||||
// "unresolvable.internal" is not in the mock DNS table — lookup throws ENOTFOUND.
|
||||
await expect(
|
||||
assertWebhookUrlAllowed("https://unresolvable.internal/hook"),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
// ── DNS-rebinding protection ──────────────────────────────────────────────────
|
||||
|
||||
it("rejects a public hostname that resolves to a private IP (DNS rebinding)", async () => {
|
||||
const { lookup } = await import("node:dns/promises");
|
||||
vi.mocked(lookup).mockResolvedValueOnce({ address: "192.168.0.1", family: 4 });
|
||||
|
||||
await expect(
|
||||
assertWebhookUrlAllowed("https://rebind.example.com/hook"),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,12 @@ vi.mock("../lib/audit.js", () => ({
|
||||
createAuditEntry: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the SSRF guard so tests do not perform real DNS lookups.
|
||||
// The guard's security behaviour is covered separately in ssrf-guard.test.ts.
|
||||
vi.mock("../lib/ssrf-guard.js", () => ({
|
||||
assertWebhookUrlAllowed: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
function createContext(db: Record<string, unknown>) {
|
||||
return {
|
||||
db: db as never,
|
||||
|
||||
Reference in New Issue
Block a user