All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
/**
|
|
* CSRF Guard Tests
|
|
*
|
|
* Tests CSRF protection using double-submit cookie pattern with session binding.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import { ExecutionContext, ForbiddenException } from "@nestjs/common";
|
|
import { Reflector } from "@nestjs/core";
|
|
import { CsrfGuard } from "./csrf.guard";
|
|
import { CsrfService } from "../services/csrf.service";
|
|
|
|
describe("CsrfGuard", () => {
|
|
let guard: CsrfGuard;
|
|
let reflector: Reflector;
|
|
let csrfService: CsrfService;
|
|
const originalEnv = process.env;
|
|
|
|
beforeEach(() => {
|
|
process.env = { ...originalEnv };
|
|
process.env.CSRF_SECRET = "test-secret-0123456789abcdef0123456789abcdef";
|
|
reflector = new Reflector();
|
|
csrfService = new CsrfService();
|
|
csrfService.onModuleInit();
|
|
guard = new CsrfGuard(reflector, csrfService);
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = originalEnv;
|
|
});
|
|
|
|
const createContext = (
|
|
method: string,
|
|
cookies: Record<string, string> = {},
|
|
headers: Record<string, string> = {},
|
|
skipCsrf = false,
|
|
userId?: string
|
|
): ExecutionContext => {
|
|
const request = {
|
|
method,
|
|
cookies,
|
|
headers,
|
|
path: "/api/test",
|
|
user: userId ? { id: userId, email: "test@example.com", name: "Test" } : undefined,
|
|
};
|
|
|
|
return {
|
|
switchToHttp: () => ({
|
|
getRequest: () => request,
|
|
}),
|
|
getHandler: () => ({}),
|
|
getClass: () => ({}),
|
|
getAllAndOverride: vi.fn().mockReturnValue(skipCsrf),
|
|
} as unknown as ExecutionContext;
|
|
};
|
|
|
|
/**
|
|
* Helper to generate a valid session-bound token
|
|
*/
|
|
const generateValidToken = (userId: string): string => {
|
|
return csrfService.generateToken(userId);
|
|
};
|
|
|
|
describe("Safe HTTP methods", () => {
|
|
it("should allow GET requests without CSRF token", () => {
|
|
const context = createContext("GET");
|
|
expect(guard.canActivate(context)).toBe(true);
|
|
});
|
|
|
|
it("should allow HEAD requests without CSRF token", () => {
|
|
const context = createContext("HEAD");
|
|
expect(guard.canActivate(context)).toBe(true);
|
|
});
|
|
|
|
it("should allow OPTIONS requests without CSRF token", () => {
|
|
const context = createContext("OPTIONS");
|
|
expect(guard.canActivate(context)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Endpoints marked to skip CSRF", () => {
|
|
it("should allow POST requests when @SkipCsrf() is applied", () => {
|
|
vi.spyOn(reflector, "getAllAndOverride").mockReturnValue(true);
|
|
const context = createContext("POST");
|
|
expect(guard.canActivate(context)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("State-changing methods requiring CSRF", () => {
|
|
it("should allow POST with Bearer auth without CSRF token", () => {
|
|
const context = createContext(
|
|
"POST",
|
|
{},
|
|
{ authorization: "Bearer api-token" },
|
|
false,
|
|
"user-123"
|
|
);
|
|
expect(guard.canActivate(context)).toBe(true);
|
|
});
|
|
|
|
it("should reject POST without CSRF token", () => {
|
|
const context = createContext("POST", {}, {}, false, "user-123");
|
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
|
expect(() => guard.canActivate(context)).toThrow("CSRF token missing");
|
|
});
|
|
|
|
it("should reject PUT without CSRF token", () => {
|
|
const context = createContext("PUT", {}, {}, false, "user-123");
|
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
|
});
|
|
|
|
it("should reject PATCH without CSRF token", () => {
|
|
const context = createContext("PATCH", {}, {}, false, "user-123");
|
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
|
});
|
|
|
|
it("should reject DELETE without CSRF token", () => {
|
|
const context = createContext("DELETE", {}, {}, false, "user-123");
|
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
|
});
|
|
|
|
it("should reject when only cookie token is present", () => {
|
|
const token = generateValidToken("user-123");
|
|
const context = createContext("POST", { "csrf-token": token }, {}, false, "user-123");
|
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
|
expect(() => guard.canActivate(context)).toThrow("CSRF token missing");
|
|
});
|
|
|
|
it("should reject when only header token is present", () => {
|
|
const token = generateValidToken("user-123");
|
|
const context = createContext("POST", {}, { "x-csrf-token": token }, false, "user-123");
|
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
|
expect(() => guard.canActivate(context)).toThrow("CSRF token missing");
|
|
});
|
|
|
|
it("should reject when tokens do not match", () => {
|
|
const token1 = generateValidToken("user-123");
|
|
const token2 = generateValidToken("user-123");
|
|
const context = createContext(
|
|
"POST",
|
|
{ "csrf-token": token1 },
|
|
{ "x-csrf-token": token2 },
|
|
false,
|
|
"user-123"
|
|
);
|
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
|
expect(() => guard.canActivate(context)).toThrow("CSRF token mismatch");
|
|
});
|
|
|
|
it("should allow when tokens match and session is valid", () => {
|
|
const token = generateValidToken("user-123");
|
|
const context = createContext(
|
|
"POST",
|
|
{ "csrf-token": token },
|
|
{ "x-csrf-token": token },
|
|
false,
|
|
"user-123"
|
|
);
|
|
expect(guard.canActivate(context)).toBe(true);
|
|
});
|
|
|
|
it("should allow PATCH when tokens match and session is valid", () => {
|
|
const token = generateValidToken("user-123");
|
|
const context = createContext(
|
|
"PATCH",
|
|
{ "csrf-token": token },
|
|
{ "x-csrf-token": token },
|
|
false,
|
|
"user-123"
|
|
);
|
|
expect(guard.canActivate(context)).toBe(true);
|
|
});
|
|
|
|
it("should allow DELETE when tokens match and session is valid", () => {
|
|
const token = generateValidToken("user-123");
|
|
const context = createContext(
|
|
"DELETE",
|
|
{ "csrf-token": token },
|
|
{ "x-csrf-token": token },
|
|
false,
|
|
"user-123"
|
|
);
|
|
expect(guard.canActivate(context)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Session binding validation", () => {
|
|
it("should allow when user context is not yet available (global guard ordering)", () => {
|
|
// CsrfGuard runs as APP_GUARD before per-controller AuthGuard,
|
|
// so request.user may not be populated. Double-submit cookie match
|
|
// is sufficient protection in this case.
|
|
const token = generateValidToken("user-123");
|
|
const context = createContext(
|
|
"POST",
|
|
{ "csrf-token": token },
|
|
{ "x-csrf-token": token },
|
|
false
|
|
// No userId - AuthGuard hasn't run yet
|
|
);
|
|
expect(guard.canActivate(context)).toBe(true);
|
|
});
|
|
|
|
it("should reject token from different session", () => {
|
|
// Token generated for user-A
|
|
const tokenForUserA = generateValidToken("user-A");
|
|
|
|
// But request is from user-B
|
|
const context = createContext(
|
|
"POST",
|
|
{ "csrf-token": tokenForUserA },
|
|
{ "x-csrf-token": tokenForUserA },
|
|
false,
|
|
"user-B" // Different user
|
|
);
|
|
|
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
|
expect(() => guard.canActivate(context)).toThrow("CSRF token not bound to session");
|
|
});
|
|
|
|
it("should reject token with invalid HMAC", () => {
|
|
// Create a token with tampered HMAC
|
|
const validToken = generateValidToken("user-123");
|
|
const parts = validToken.split(":");
|
|
const tamperedToken = `${parts[0]}:0000000000000000000000000000000000000000000000000000000000000000`;
|
|
|
|
const context = createContext(
|
|
"POST",
|
|
{ "csrf-token": tamperedToken },
|
|
{ "x-csrf-token": tamperedToken },
|
|
false,
|
|
"user-123"
|
|
);
|
|
|
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
|
expect(() => guard.canActivate(context)).toThrow("CSRF token not bound to session");
|
|
});
|
|
|
|
it("should reject token with invalid format", () => {
|
|
const invalidToken = "not-a-valid-token";
|
|
|
|
const context = createContext(
|
|
"POST",
|
|
{ "csrf-token": invalidToken },
|
|
{ "x-csrf-token": invalidToken },
|
|
false,
|
|
"user-123"
|
|
);
|
|
|
|
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
|
expect(() => guard.canActivate(context)).toThrow("CSRF token not bound to session");
|
|
});
|
|
|
|
it("should not allow token reuse across sessions", () => {
|
|
// Generate token for user-A
|
|
const tokenA = generateValidToken("user-A");
|
|
|
|
// Valid for user-A
|
|
const contextA = createContext(
|
|
"POST",
|
|
{ "csrf-token": tokenA },
|
|
{ "x-csrf-token": tokenA },
|
|
false,
|
|
"user-A"
|
|
);
|
|
expect(guard.canActivate(contextA)).toBe(true);
|
|
|
|
// Invalid for user-B
|
|
const contextB = createContext(
|
|
"POST",
|
|
{ "csrf-token": tokenA },
|
|
{ "x-csrf-token": tokenA },
|
|
false,
|
|
"user-B"
|
|
);
|
|
expect(() => guard.canActivate(contextB)).toThrow("CSRF token not bound to session");
|
|
|
|
// Invalid for user-C
|
|
const contextC = createContext(
|
|
"POST",
|
|
{ "csrf-token": tokenA },
|
|
{ "x-csrf-token": tokenA },
|
|
false,
|
|
"user-C"
|
|
);
|
|
expect(() => guard.canActivate(contextC)).toThrow("CSRF token not bound to session");
|
|
});
|
|
|
|
it("should allow each user to use only their own token", () => {
|
|
const tokenA = generateValidToken("user-A");
|
|
const tokenB = generateValidToken("user-B");
|
|
|
|
// User A with token A - valid
|
|
const contextAA = createContext(
|
|
"POST",
|
|
{ "csrf-token": tokenA },
|
|
{ "x-csrf-token": tokenA },
|
|
false,
|
|
"user-A"
|
|
);
|
|
expect(guard.canActivate(contextAA)).toBe(true);
|
|
|
|
// User B with token B - valid
|
|
const contextBB = createContext(
|
|
"POST",
|
|
{ "csrf-token": tokenB },
|
|
{ "x-csrf-token": tokenB },
|
|
false,
|
|
"user-B"
|
|
);
|
|
expect(guard.canActivate(contextBB)).toBe(true);
|
|
|
|
// User A with token B - invalid (cross-session)
|
|
const contextAB = createContext(
|
|
"POST",
|
|
{ "csrf-token": tokenB },
|
|
{ "x-csrf-token": tokenB },
|
|
false,
|
|
"user-A"
|
|
);
|
|
expect(() => guard.canActivate(contextAB)).toThrow("CSRF token not bound to session");
|
|
|
|
// User B with token A - invalid (cross-session)
|
|
const contextBA = createContext(
|
|
"POST",
|
|
{ "csrf-token": tokenA },
|
|
{ "x-csrf-token": tokenA },
|
|
false,
|
|
"user-B"
|
|
);
|
|
expect(() => guard.canActivate(contextBA)).toThrow("CSRF token not bound to session");
|
|
});
|
|
});
|
|
});
|