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:
2026-07-22 20:21:25 -04:00
parent e446eb1775
commit ff956be80f
29 changed files with 2008 additions and 365 deletions

View File

@@ -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);
});
});
});

View File

@@ -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 } : {})
};