feat: implement comprehensive CSRF protection

This commit is contained in:
2025-07-11 18:06:51 +02:00
committed by Kaj Kowalski
parent e7818f5e4f
commit 3e9e75e854
44 changed files with 14964 additions and 6413 deletions

View File

@ -0,0 +1,253 @@
/**
* CSRF Protection Integration Tests
*
* End-to-end tests for CSRF protection in API endpoints and middleware.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { createMocks } from "node-mocks-http";
import type { NextRequest } from "next/server";
import { csrfProtectionMiddleware, csrfTokenMiddleware } from "../../middleware/csrfProtection";
import { generateCSRFToken } from "../../lib/csrf";
describe("CSRF Protection Integration", () => {
describe("CSRF Token Middleware", () => {
it("should serve CSRF token on GET /api/csrf-token", async () => {
const { req } = createMocks({
method: "GET",
url: "/api/csrf-token",
});
const request = {
method: "GET",
nextUrl: { pathname: "/api/csrf-token" },
} as NextRequest;
const response = csrfTokenMiddleware(request);
expect(response).not.toBeNull();
if (response) {
const body = await response.json();
expect(body.success).toBe(true);
expect(body.token).toBeDefined();
expect(typeof body.token).toBe("string");
}
});
it("should return null for non-csrf-token paths", async () => {
const request = {
method: "GET",
nextUrl: { pathname: "/api/other" },
} as NextRequest;
const response = csrfTokenMiddleware(request);
expect(response).toBeNull();
});
});
describe("CSRF Protection Middleware", () => {
it("should allow GET requests without CSRF token", async () => {
const request = {
method: "GET",
nextUrl: { pathname: "/api/dashboard" },
} as NextRequest;
const response = await csrfProtectionMiddleware(request);
expect(response.status).not.toBe(403);
});
it("should allow HEAD requests without CSRF token", async () => {
const request = {
method: "HEAD",
nextUrl: { pathname: "/api/dashboard" },
} as NextRequest;
const response = await csrfProtectionMiddleware(request);
expect(response.status).not.toBe(403);
});
it("should allow OPTIONS requests without CSRF token", async () => {
const request = {
method: "OPTIONS",
nextUrl: { pathname: "/api/dashboard" },
} as NextRequest;
const response = await csrfProtectionMiddleware(request);
expect(response.status).not.toBe(403);
});
it("should block POST request to protected endpoint without CSRF token", async () => {
const request = {
method: "POST",
nextUrl: { pathname: "/api/dashboard/sessions" },
headers: new Headers({
"Content-Type": "application/json",
}),
cookies: {
get: () => undefined,
},
clone: () => ({
json: async () => ({}),
}),
} as any;
const response = await csrfProtectionMiddleware(request);
expect(response.status).toBe(403);
const body = await response.json();
expect(body.success).toBe(false);
expect(body.error).toContain("CSRF token");
});
it("should allow POST request to unprotected endpoint without CSRF token", async () => {
const request = {
method: "POST",
nextUrl: { pathname: "/api/unprotected" },
} as NextRequest;
const response = await csrfProtectionMiddleware(request);
expect(response.status).not.toBe(403);
});
it("should allow POST request with valid CSRF token", async () => {
const token = generateCSRFToken();
const request = {
method: "POST",
nextUrl: { pathname: "/api/dashboard/sessions" },
headers: new Headers({
"Content-Type": "application/json",
"x-csrf-token": token,
}),
cookies: {
get: () => ({ value: token }),
},
clone: () => ({
json: async () => ({ csrfToken: token }),
}),
} as any;
const response = await csrfProtectionMiddleware(request);
expect(response.status).not.toBe(403);
});
it("should block POST request with mismatched CSRF tokens", async () => {
const headerToken = generateCSRFToken();
const cookieToken = generateCSRFToken();
const request = {
method: "POST",
nextUrl: { pathname: "/api/dashboard/sessions" },
headers: new Headers({
"Content-Type": "application/json",
"x-csrf-token": headerToken,
}),
cookies: {
get: () => ({ value: cookieToken }),
},
clone: () => ({
json: async () => ({ csrfToken: headerToken }),
}),
} as any;
const response = await csrfProtectionMiddleware(request);
expect(response.status).toBe(403);
});
it("should protect all state-changing methods", async () => {
const methods = ["POST", "PUT", "DELETE", "PATCH"];
for (const method of methods) {
const request = {
method,
nextUrl: { pathname: "/api/trpc/test" },
headers: new Headers({
"Content-Type": "application/json",
}),
cookies: {
get: () => undefined,
},
clone: () => ({
json: async () => ({}),
}),
} as any;
const response = await csrfProtectionMiddleware(request);
expect(response.status).toBe(403);
}
});
});
describe("Protected Endpoints", () => {
const protectedPaths = [
"/api/auth/signin",
"/api/register",
"/api/forgot-password",
"/api/reset-password",
"/api/dashboard/sessions",
"/api/platform/companies",
"/api/trpc/test",
];
protectedPaths.forEach((path) => {
it(`should protect ${path} endpoint`, async () => {
const request = {
method: "POST",
nextUrl: { pathname: path },
headers: new Headers({
"Content-Type": "application/json",
}),
cookies: {
get: () => undefined,
},
clone: () => ({
json: async () => ({}),
}),
} as any;
const response = await csrfProtectionMiddleware(request);
expect(response.status).toBe(403);
});
});
});
describe("Error Handling", () => {
it("should handle malformed requests gracefully", async () => {
const request = {
method: "POST",
nextUrl: { pathname: "/api/dashboard/sessions" },
headers: new Headers({
"Content-Type": "application/json",
}),
cookies: {
get: () => undefined,
},
clone: () => ({
json: async () => {
throw new Error("Malformed JSON");
},
}),
} as any;
const response = await csrfProtectionMiddleware(request);
expect(response.status).toBe(403);
});
it("should handle missing headers gracefully", async () => {
const request = {
method: "POST",
nextUrl: { pathname: "/api/dashboard/sessions" },
headers: new Headers(),
cookies: {
get: () => undefined,
},
clone: () => ({
json: async () => ({}),
}),
} as any;
const response = await csrfProtectionMiddleware(request);
expect(response.status).toBe(403);
});
});
});

View File

@ -1,6 +1,6 @@
/**
* Integration tests for CSV import workflow
*
*
* Tests the complete end-to-end flow of CSV import:
* 1. CSV file fetching from URL
* 2. Parsing and validation of CSV data
@ -109,7 +109,7 @@ session2,user2,nl,NL,192.168.1.2,neutral,3,2024-01-15T11:00:00Z,2024-01-15T11:20
expect(options.headers.Authorization).toBe(
`Basic ${Buffer.from("testuser:testpass").toString("base64")}`
);
return Promise.resolve({
ok: true,
text: async () => mockCsvData,
@ -185,7 +185,7 @@ session2,user2,nl,NL,192.168.1.2,neutral,3,2024-01-15T11:00:00Z,2024-01-15T11:20
it("should handle invalid CSV format", async () => {
const invalidCsv = "invalid,csv,data\nwithout,proper,headers";
const fetchMock = await import("node-fetch");
vi.mocked(fetchMock.default).mockResolvedValueOnce({
ok: true,
@ -347,13 +347,13 @@ session4,user4,en,US,192.168.1.4,positive,5,2024-01-15T10:00:00Z,2024-01-15T10:3
it("should handle large CSV files efficiently", async () => {
// Generate large CSV with 1000 rows
const largeCSVRows = ["sessionId,userId,language,country,ipAddress,sentiment,messagesSent,startTime,endTime,escalated,forwardedHr,summary"];
for (let i = 0; i < 1000; i++) {
largeCSVRows.push(
`session${i},user${i},en,US,192.168.1.${i % 255},positive,5,2024-01-15T10:00:00Z,2024-01-15T10:30:00Z,false,false,Session ${i}`
);
}
const largeCsv = largeCSVRows.join("\n");
const fetchMock = await import("node-fetch");

View File

@ -0,0 +1,238 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import crypto from "node:crypto";
// Mock dependencies before importing auth router
vi.mock("../../lib/prisma", () => ({
prisma: {
user: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("bcryptjs", () => ({
default: {
hash: vi.fn().mockResolvedValue("hashed-password"),
},
}));
describe("Password Reset Flow Integration", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("Forgot Password Flow", () => {
it("should generate secure tokens during password reset request", async () => {
// Import after mocks are set up
const { authRouter } = await import("../../server/routers/auth");
const { prisma } = await import("../../lib/prisma");
const testUser = {
id: "user-123",
email: "test@example.com",
password: "hashed-password",
resetToken: null,
resetTokenExpiry: null,
companyId: "company-123",
role: "USER" as const,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(testUser);
let capturedToken: string | undefined;
vi.mocked(prisma.user.update).mockImplementation(async ({ data }) => {
capturedToken = data.resetToken;
return {
...testUser,
resetToken: data.resetToken,
resetTokenExpiry: data.resetTokenExpiry,
};
});
// Create a mock tRPC context
const ctx = {
prisma,
session: null,
};
// Call the forgotPassword procedure directly
const result = await authRouter
.createCaller(ctx)
.forgotPassword({ email: "test@example.com" });
expect(result.message).toContain("password reset link");
expect(prisma.user.update).toHaveBeenCalled();
// Verify the token was generated with proper security characteristics
expect(capturedToken).toBeDefined();
expect(capturedToken).toHaveLength(64);
expect(capturedToken).toMatch(/^[0-9a-f]{64}$/);
});
it("should generate different tokens for consecutive requests", async () => {
// Import after mocks are set up
const { authRouter } = await import("../../server/routers/auth");
const { prisma } = await import("../../lib/prisma");
const testUser = {
id: "user-123",
email: "test@example.com",
password: "hashed-password",
resetToken: null,
resetTokenExpiry: null,
companyId: "company-123",
role: "USER" as const,
createdAt: new Date(),
updatedAt: new Date(),
};
const capturedTokens: string[] = [];
vi.mocked(prisma.user.findUnique).mockResolvedValue(testUser);
vi.mocked(prisma.user.update).mockImplementation(async ({ data }) => {
capturedTokens.push(data.resetToken);
return {
...testUser,
resetToken: data.resetToken,
resetTokenExpiry: data.resetTokenExpiry,
};
});
const ctx = {
prisma,
session: null,
};
// Generate multiple tokens
await authRouter.createCaller(ctx).forgotPassword({ email: "test@example.com" });
await authRouter.createCaller(ctx).forgotPassword({ email: "test@example.com" });
await authRouter.createCaller(ctx).forgotPassword({ email: "test@example.com" });
expect(capturedTokens).toHaveLength(3);
expect(capturedTokens[0]).not.toBe(capturedTokens[1]);
expect(capturedTokens[1]).not.toBe(capturedTokens[2]);
expect(capturedTokens[0]).not.toBe(capturedTokens[2]);
// All tokens should be properly formatted
capturedTokens.forEach(token => {
expect(token).toHaveLength(64);
expect(token).toMatch(/^[0-9a-f]{64}$/);
});
});
});
describe("Reset Password Flow", () => {
it("should accept secure tokens for password reset", async () => {
// Import after mocks are set up
const { authRouter } = await import("../../server/routers/auth");
const { prisma } = await import("../../lib/prisma");
const secureToken = crypto.randomBytes(32).toString('hex');
const futureDate = new Date(Date.now() + 3600000);
const userWithResetToken = {
id: "user-123",
email: "test@example.com",
password: "old-hashed-password",
resetToken: secureToken,
resetTokenExpiry: futureDate,
companyId: "company-123",
role: "USER" as const,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(prisma.user.findFirst).mockResolvedValueOnce(userWithResetToken);
vi.mocked(prisma.user.update).mockResolvedValueOnce({
...userWithResetToken,
password: "new-hashed-password",
resetToken: null,
resetTokenExpiry: null,
});
const ctx = {
prisma,
session: null,
};
const result = await authRouter
.createCaller(ctx)
.resetPassword({
token: secureToken,
password: "NewSecurePassword123!",
});
expect(result.message).toBe("Password reset successfully");
expect(prisma.user.findFirst).toHaveBeenCalledWith({
where: {
resetToken: secureToken,
resetTokenExpiry: {
gt: expect.any(Date),
},
},
});
});
it("should reject invalid token formats", async () => {
// Import after mocks are set up
const { authRouter } = await import("../../server/routers/auth");
const { prisma } = await import("../../lib/prisma");
const invalidTokens = [
"short",
"invalid-chars-@#$",
Math.random().toString(36).substring(2, 15), // Old weak format
"0".repeat(63), // Wrong length
"g".repeat(64), // Invalid hex chars
];
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
const ctx = {
prisma,
session: null,
};
for (const invalidToken of invalidTokens) {
await expect(
authRouter.createCaller(ctx).resetPassword({
token: invalidToken,
password: "NewSecurePassword123!",
})
).rejects.toThrow("Invalid or expired reset token");
}
});
});
describe("Token Security Comparison", () => {
it("should demonstrate improvement over weak Math.random() tokens", () => {
// Generate tokens using both methods
const secureTokens = Array.from({ length: 100 }, () =>
crypto.randomBytes(32).toString('hex')
);
const weakTokens = Array.from({ length: 100 }, () =>
Math.random().toString(36).substring(2, 15)
);
// Secure tokens should be longer
const avgSecureLength = secureTokens.reduce((sum, t) => sum + t.length, 0) / secureTokens.length;
const avgWeakLength = weakTokens.reduce((sum, t) => sum + t.length, 0) / weakTokens.length;
expect(avgSecureLength).toBeGreaterThan(avgWeakLength * 4);
// Secure tokens should have no collisions
expect(new Set(secureTokens).size).toBe(secureTokens.length);
// Weak tokens might have collisions with enough samples
// but more importantly, they're predictable
secureTokens.forEach(token => {
expect(token).toMatch(/^[0-9a-f]{64}$/);
});
});
});
});

View File

@ -0,0 +1,156 @@
import { describe, it, expect } from "vitest";
describe("Security Headers Configuration", () => {
describe("Next.js Config Validation", () => {
it("should have valid security headers configuration", async () => {
// Import the Next.js config
const nextConfig = await import("../../next.config.js");
expect(nextConfig.default).toBeDefined();
expect(nextConfig.default.headers).toBeDefined();
expect(typeof nextConfig.default.headers).toBe("function");
});
it("should generate expected headers structure", async () => {
const nextConfig = await import("../../next.config.js");
const headers = await nextConfig.default.headers();
expect(Array.isArray(headers)).toBe(true);
expect(headers.length).toBeGreaterThan(0);
// Find the main security headers configuration
const securityConfig = headers.find(h => h.source === "/(.*)" && h.headers.length > 1);
expect(securityConfig).toBeDefined();
if (securityConfig) {
const headerNames = securityConfig.headers.map(h => h.key);
// Check required security headers are present
expect(headerNames).toContain("X-Content-Type-Options");
expect(headerNames).toContain("X-Frame-Options");
expect(headerNames).toContain("X-XSS-Protection");
expect(headerNames).toContain("Referrer-Policy");
expect(headerNames).toContain("Content-Security-Policy");
expect(headerNames).toContain("Permissions-Policy");
expect(headerNames).toContain("X-DNS-Prefetch-Control");
}
});
it("should have correct security header values", async () => {
const nextConfig = await import("../../next.config.js");
const headers = await nextConfig.default.headers();
const securityConfig = headers.find(h => h.source === "/(.*)" && h.headers.length > 1);
if (securityConfig) {
const headerMap = new Map(securityConfig.headers.map(h => [h.key, h.value]));
expect(headerMap.get("X-Content-Type-Options")).toBe("nosniff");
expect(headerMap.get("X-Frame-Options")).toBe("DENY");
expect(headerMap.get("X-XSS-Protection")).toBe("1; mode=block");
expect(headerMap.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
expect(headerMap.get("X-DNS-Prefetch-Control")).toBe("off");
// CSP should contain essential directives
const csp = headerMap.get("Content-Security-Policy");
expect(csp).toContain("default-src 'self'");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).toContain("object-src 'none'");
// Permissions Policy should restrict dangerous features
const permissions = headerMap.get("Permissions-Policy");
expect(permissions).toContain("camera=()");
expect(permissions).toContain("microphone=()");
expect(permissions).toContain("geolocation=()");
}
});
it("should handle HSTS header based on environment", async () => {
const nextConfig = await import("../../next.config.js");
// Test production environment
const originalEnv = process.env.NODE_ENV;
process.env.NODE_ENV = "production";
const prodHeaders = await nextConfig.default.headers();
const hstsConfig = prodHeaders.find(h =>
h.headers.some(header => header.key === "Strict-Transport-Security")
);
if (hstsConfig) {
const hstsHeader = hstsConfig.headers.find(h => h.key === "Strict-Transport-Security");
expect(hstsHeader?.value).toBe("max-age=31536000; includeSubDomains; preload");
}
// Test development environment
process.env.NODE_ENV = "development";
const devHeaders = await nextConfig.default.headers();
const devHstsConfig = devHeaders.find(h =>
h.headers.some(header => header.key === "Strict-Transport-Security")
);
// In development, HSTS header array should be empty
if (devHstsConfig) {
expect(devHstsConfig.headers.length).toBe(0);
}
// Restore original environment
process.env.NODE_ENV = originalEnv;
});
});
describe("CSP Directive Validation", () => {
it("should have comprehensive CSP directives", async () => {
const nextConfig = await import("../../next.config.js");
const headers = await nextConfig.default.headers();
const securityConfig = headers.find(h => h.source === "/(.*)" && h.headers.length > 1);
const cspHeader = securityConfig?.headers.find(h => h.key === "Content-Security-Policy");
expect(cspHeader).toBeDefined();
if (cspHeader) {
const csp = cspHeader.value;
// Essential security directives
expect(csp).toContain("default-src 'self'");
expect(csp).toContain("object-src 'none'");
expect(csp).toContain("base-uri 'self'");
expect(csp).toContain("form-action 'self'");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).toContain("upgrade-insecure-requests");
// Next.js compatibility directives
expect(csp).toContain("script-src 'self' 'unsafe-eval' 'unsafe-inline'");
expect(csp).toContain("style-src 'self' 'unsafe-inline'");
expect(csp).toContain("img-src 'self' data: https:");
expect(csp).toContain("font-src 'self' data:");
expect(csp).toContain("connect-src 'self' https:");
}
});
});
describe("Permissions Policy Validation", () => {
it("should restrict dangerous browser features", async () => {
const nextConfig = await import("../../next.config.js");
const headers = await nextConfig.default.headers();
const securityConfig = headers.find(h => h.source === "/(.*)" && h.headers.length > 1);
const permissionsHeader = securityConfig?.headers.find(h => h.key === "Permissions-Policy");
expect(permissionsHeader).toBeDefined();
if (permissionsHeader) {
const permissions = permissionsHeader.value;
// Should disable privacy-sensitive features
expect(permissions).toContain("camera=()");
expect(permissions).toContain("microphone=()");
expect(permissions).toContain("geolocation=()");
expect(permissions).toContain("interest-cohort=()");
expect(permissions).toContain("browsing-topics=()");
}
});
});
});

View File

@ -1,6 +1,6 @@
/**
* Integration tests for session processing workflow
*
*
* Tests the complete end-to-end flow of session processing:
* 1. Import processing (SessionImport → Session)
* 2. Transcript fetching and parsing