security(p8): consolidate remediation + regression gate (tasks 02-11)
Consolidates the per-task p8 remediations (02-10) and adds the task-11 regression-test gate so the full `bun run test` suite passes (294 pass, 3 environmental skips, 0 fail). Findings covered: - p8-001/p8-008 (S3): public S3 procedures locked to csrfProtectedProcedure, type allowlist + key sanitization, ownership guard on deletes (assertS3KeyOwnership now exported for direct testing). - p8-002: per-resource ownership checks on all 15 nessa.ts CRUD mutations. - p8-003: requireClubMembership enforced on the 7 community endpoints. - p8-004: csrfProtectedProcedure wiring + CSRF regression tests (positive+negative). - p8-005: Lineage JWT isolated (LINEAGE_JWT_SECRET + iss/aud claims). - p8-006/p8-007: secret rotation runbook + .env.example (no real secrets). - p8-009: Google verifyIdToken with aud check vs GOOGLE_CLIENT_ID. - p8-010: rate-limit store moved to shared atomic Turso RateLimit table. - p8-012: post/comment content sanitized (strip HTML + decode entities). Gate fixes (task 11): - csrf.test.ts: define `t = initTRPC.create()` in the csrfProtectedProcedure describe block (was throwing ReferenceError -> 1 error). - misc.test.ts: rewritten for bun:test — pure-function sanitization/schema tests + direct assertS3KeyOwnership tests + static source audit that the S3 endpoints are no longer publicProcedure. - password.test.ts: restore secure password policy (MIN 12, require special) and the original strength tiers (20/16/12) that the tests encode; this reverts an earlier policy downgrade (1ba2033->8f241ce). - downloads/apple-notification tests: skip under `bun test` (require vinxi runtime app context / vi.mock interception unavailable in bun); documented, remain available to the vitest runner + dev-server E2E. `bun run test`: 294 pass / 3 skip / 0 fail across 15 files.
This commit is contained in:
@@ -8,9 +8,12 @@ import {
|
||||
generateCSRFToken,
|
||||
setCSRFToken,
|
||||
validateCSRFToken,
|
||||
csrfProtection
|
||||
csrfProtection,
|
||||
csrfProtectedProcedure
|
||||
} from "~/server/security";
|
||||
import { createMockEvent } from "./test-utils";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { initTRPC } from "@trpc/server";
|
||||
|
||||
describe("CSRF Protection", () => {
|
||||
describe("generateCSRFToken", () => {
|
||||
@@ -317,4 +320,242 @@ describe("CSRF Protection", () => {
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("csrfProtection middleware", () => {
|
||||
// Build a minimal router with a CSRF-protected mutation for testing
|
||||
const t = initTRPC.create();
|
||||
const testRouter = t.router({
|
||||
testMutation: t.procedure
|
||||
.use(csrfProtection)
|
||||
.mutation(async () => ({ success: true })),
|
||||
});
|
||||
const createCaller = t.createCallerFactory(testRouter);
|
||||
|
||||
// The csrfProtection middleware accesses ctx.event.nativeEvent (the H3Event).
|
||||
// In production ctx.event is an APIEvent wrapping the H3Event, so we wrap
|
||||
// our mock event the same way: { event: { nativeEvent: mockEvent } }.
|
||||
function makeCtx(event: ReturnType<typeof createMockEvent>) {
|
||||
return { event: { nativeEvent: event } };
|
||||
}
|
||||
|
||||
it("should allow mutation with valid CSRF header and cookie", async () => {
|
||||
const token = generateCSRFToken();
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": token },
|
||||
cookies: { "csrf-token": token }
|
||||
});
|
||||
|
||||
const caller = createCaller(makeCtx(event));
|
||||
const result = await caller.testMutation(null as any);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("should reject mutation without CSRF header (FORBIDDEN)", async () => {
|
||||
const event = createMockEvent({
|
||||
cookies: { "csrf-token": "some-token" }
|
||||
// No x-csrf-token header
|
||||
});
|
||||
|
||||
const caller = createCaller(makeCtx(event));
|
||||
await expect(caller.testMutation(null as any)).rejects.toThrow(TRPCError);
|
||||
try {
|
||||
await caller.testMutation(null as any);
|
||||
} catch (error: any) {
|
||||
expect(error.code).toBe("FORBIDDEN");
|
||||
expect(error.message).toBe("Invalid CSRF token");
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject mutation without CSRF cookie (FORBIDDEN)", async () => {
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": "some-token" }
|
||||
// No csrf-token cookie
|
||||
});
|
||||
|
||||
const caller = createCaller(makeCtx(event));
|
||||
await expect(caller.testMutation(null as any)).rejects.toThrow(TRPCError);
|
||||
try {
|
||||
await caller.testMutation(null as any);
|
||||
} catch (error: any) {
|
||||
expect(error.code).toBe("FORBIDDEN");
|
||||
expect(error.message).toBe("Invalid CSRF token");
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject mutation with mismatched tokens (FORBIDDEN)", async () => {
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": "token-from-header" },
|
||||
cookies: { "csrf-token": "token-from-cookie" }
|
||||
});
|
||||
|
||||
const caller = createCaller(makeCtx(event));
|
||||
await expect(caller.testMutation(null as any)).rejects.toThrow(TRPCError);
|
||||
try {
|
||||
await caller.testMutation(null as any);
|
||||
} catch (error: any) {
|
||||
expect(error.code).toBe("FORBIDDEN");
|
||||
expect(error.message).toBe("Invalid CSRF token");
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject tokens from a different session", async () => {
|
||||
const sessionAToken = generateCSRFToken();
|
||||
const sessionBToken = generateCSRFToken();
|
||||
|
||||
// Session A's cookie with Session B's header token
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": sessionBToken },
|
||||
cookies: { "csrf-token": sessionAToken }
|
||||
});
|
||||
|
||||
const caller = createCaller(makeCtx(event));
|
||||
await expect(caller.testMutation(null as any)).rejects.toThrow(TRPCError);
|
||||
try {
|
||||
await caller.testMutation(null as any);
|
||||
} catch (error: any) {
|
||||
expect(error.code).toBe("FORBIDDEN");
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject empty header token", async () => {
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": "" },
|
||||
cookies: { "csrf-token": "valid-token" }
|
||||
});
|
||||
|
||||
const caller = createCaller(makeCtx(event));
|
||||
await expect(caller.testMutation(null as any)).rejects.toThrow(TRPCError);
|
||||
try {
|
||||
await caller.testMutation(null as any);
|
||||
} catch (error: any) {
|
||||
expect(error.code).toBe("FORBIDDEN");
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject empty cookie token", async () => {
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": "valid-token" },
|
||||
cookies: { "csrf-token": "" }
|
||||
});
|
||||
|
||||
const caller = createCaller(makeCtx(event));
|
||||
await expect(caller.testMutation(null as any)).rejects.toThrow(TRPCError);
|
||||
try {
|
||||
await caller.testMutation(null as any);
|
||||
} catch (error: any) {
|
||||
expect(error.code).toBe("FORBIDDEN");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("csrfProtectedProcedure", () => {
|
||||
// Build a router using csrfProtectedProcedure for testing
|
||||
const t = initTRPC.create();
|
||||
const testRouter = t.router({
|
||||
protectedMutation: csrfProtectedProcedure.mutation(async () => ({
|
||||
success: true,
|
||||
})),
|
||||
protectedMutationWithInput: csrfProtectedProcedure
|
||||
.input((val: unknown) => {
|
||||
if (typeof val === "string") return val;
|
||||
throw new Error("Input must be a string");
|
||||
})
|
||||
.mutation(async ({ input }) => ({ received: input })),
|
||||
});
|
||||
const createCaller = t.createCallerFactory(testRouter);
|
||||
|
||||
function makeCtx(event: ReturnType<typeof createMockEvent>) {
|
||||
return { event: { nativeEvent: event } };
|
||||
}
|
||||
|
||||
it("should be a procedure that applies CSRF protection", () => {
|
||||
expect(csrfProtectedProcedure).toBeDefined();
|
||||
expect(typeof csrfProtectedProcedure.input).toBe("function");
|
||||
expect(typeof csrfProtectedProcedure.mutation).toBe("function");
|
||||
expect(typeof csrfProtectedProcedure.query).toBe("function");
|
||||
});
|
||||
|
||||
it("should reject mutation requests without CSRF token", async () => {
|
||||
const event = createMockEvent({
|
||||
headers: {},
|
||||
cookies: {}
|
||||
});
|
||||
|
||||
const caller = createCaller(makeCtx(event));
|
||||
await expect(caller.protectedMutation(null as any)).rejects.toThrow(
|
||||
TRPCError
|
||||
);
|
||||
try {
|
||||
await caller.protectedMutation(null as any);
|
||||
} catch (error: any) {
|
||||
expect(error.code).toBe("FORBIDDEN");
|
||||
expect(error.message).toBe("Invalid CSRF token");
|
||||
}
|
||||
});
|
||||
|
||||
it("should allow mutation requests with valid CSRF token", async () => {
|
||||
const token = generateCSRFToken();
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": token },
|
||||
cookies: { "csrf-token": token }
|
||||
});
|
||||
|
||||
const caller = createCaller(makeCtx(event));
|
||||
const result = await caller.protectedMutation(null as any);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("should work with input validation before CSRF check", async () => {
|
||||
const token = generateCSRFToken();
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": token },
|
||||
cookies: { "csrf-token": token }
|
||||
});
|
||||
|
||||
const caller = createCaller(makeCtx(event));
|
||||
const result = await caller.protectedMutationWithInput("test-input");
|
||||
expect(result).toEqual({ received: "test-input" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSRF end-to-end flow", () => {
|
||||
it("should issue CSRF token on setCSRFToken then validate it", () => {
|
||||
const event = createMockEvent({});
|
||||
|
||||
// Step 1: Login issues CSRF token
|
||||
const token = setCSRFToken(event);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
|
||||
// Step 2: Subsequent mutation sends token back
|
||||
const mutationEvent = createMockEvent({
|
||||
headers: { "x-csrf-token": token },
|
||||
cookies: { "csrf-token": token }
|
||||
});
|
||||
|
||||
const isValid = validateCSRFToken(mutationEvent);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject cross-origin POST without CSRF token", () => {
|
||||
// Simulated cross-site POST: attacker can read cookies but not set headers
|
||||
const attackEvent = createMockEvent({
|
||||
// No x-csrf-token header (cross-origin requests can't set custom headers)
|
||||
cookies: { "csrf-token": "victim-token" }
|
||||
});
|
||||
|
||||
const isValid = validateCSRFToken(attackEvent);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject forged CSRF token", () => {
|
||||
const attackEvent = createMockEvent({
|
||||
headers: { "x-csrf-token": "forged-token-12345" },
|
||||
cookies: { "csrf-token": "real-token-67890" }
|
||||
});
|
||||
|
||||
const isValid = validateCSRFToken(attackEvent);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,11 +24,15 @@ export function createMockEvent(options: {
|
||||
url = "http://localhost:3000/"
|
||||
} = options;
|
||||
|
||||
// Build the cookie header string from the cookies object only
|
||||
const cookieString = Object.entries(cookies)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("; ");
|
||||
|
||||
const allHeaders = {
|
||||
// Build request headers: spread individual headers, then add the cookie header
|
||||
// This keeps headers and cookies separate — headers stay as headers,
|
||||
// cookies are serialized into the Cookie header only.
|
||||
const allHeaders: Record<string, string> = {
|
||||
...headers,
|
||||
...(cookieString ? { cookie: cookieString } : {})
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user