security hardening
This commit is contained in:
486
src/server/security/auth.test.ts
Normal file
486
src/server/security/auth.test.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* Authentication Security Tests
|
||||
* Tests for authentication mechanisms including JWT, session management, and timing attacks
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "bun:test";
|
||||
import { getUserID, getPrivilegeLevel, checkAuthStatus } from "~/server/auth";
|
||||
import {
|
||||
createMockEvent,
|
||||
createTestJWT,
|
||||
createExpiredJWT,
|
||||
createInvalidSignatureJWT,
|
||||
measureTime
|
||||
} from "./test-utils";
|
||||
import { jwtVerify, SignJWT } from "jose";
|
||||
import { env } from "~/env/server";
|
||||
|
||||
describe("Authentication Security", () => {
|
||||
describe("JWT Token Validation", () => {
|
||||
it("should validate correct JWT tokens", async () => {
|
||||
const userId = "test-user-123";
|
||||
const token = await createTestJWT(userId);
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: token }
|
||||
});
|
||||
|
||||
const extractedUserId = await getUserID(event);
|
||||
expect(extractedUserId).toBe(userId);
|
||||
});
|
||||
|
||||
it("should reject expired JWT tokens", async () => {
|
||||
const userId = "test-user-123";
|
||||
const expiredToken = await createExpiredJWT(userId);
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: expiredToken }
|
||||
});
|
||||
|
||||
const extractedUserId = await getUserID(event);
|
||||
expect(extractedUserId).toBeNull();
|
||||
});
|
||||
|
||||
it("should reject JWT tokens with invalid signature", async () => {
|
||||
const userId = "test-user-123";
|
||||
const invalidToken = await createInvalidSignatureJWT(userId);
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: invalidToken }
|
||||
});
|
||||
|
||||
const extractedUserId = await getUserID(event);
|
||||
expect(extractedUserId).toBeNull();
|
||||
});
|
||||
|
||||
it("should reject malformed JWT tokens", async () => {
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: "not-a-valid-jwt" }
|
||||
});
|
||||
|
||||
const extractedUserId = await getUserID(event);
|
||||
expect(extractedUserId).toBeNull();
|
||||
});
|
||||
|
||||
it("should reject empty JWT tokens", async () => {
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: "" }
|
||||
});
|
||||
|
||||
const extractedUserId = await getUserID(event);
|
||||
expect(extractedUserId).toBeNull();
|
||||
});
|
||||
|
||||
it("should reject JWT tokens with missing user ID", async () => {
|
||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||
const tokenWithoutId = await new SignJWT({})
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("1h")
|
||||
.sign(secret);
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: tokenWithoutId }
|
||||
});
|
||||
|
||||
const extractedUserId = await getUserID(event);
|
||||
expect(extractedUserId).toBeNull();
|
||||
});
|
||||
|
||||
it("should reject JWT tokens with invalid user ID type", async () => {
|
||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||
const tokenWithNumberId = await new SignJWT({ id: 12345 })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("1h")
|
||||
.sign(secret);
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: tokenWithNumberId }
|
||||
});
|
||||
|
||||
const extractedUserId = await getUserID(event);
|
||||
expect(extractedUserId).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle missing cookie gracefully", async () => {
|
||||
const event = createMockEvent({});
|
||||
const extractedUserId = await getUserID(event);
|
||||
expect(extractedUserId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("JWT Token Tampering", () => {
|
||||
it("should detect modified JWT payload", async () => {
|
||||
const userId = "test-user-123";
|
||||
const token = await createTestJWT(userId);
|
||||
|
||||
// Tamper with the payload (middle part of JWT)
|
||||
const parts = token.split(".");
|
||||
const tamperedPayload = Buffer.from(
|
||||
JSON.stringify({ id: "attacker-id" })
|
||||
).toString("base64url");
|
||||
const tamperedToken = `${parts[0]}.${tamperedPayload}.${parts[2]}`;
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: tamperedToken }
|
||||
});
|
||||
|
||||
const extractedUserId = await getUserID(event);
|
||||
expect(extractedUserId).toBeNull();
|
||||
});
|
||||
|
||||
it("should detect modified JWT signature", async () => {
|
||||
const userId = "test-user-123";
|
||||
const token = await createTestJWT(userId);
|
||||
|
||||
// Tamper with the signature (last part of JWT)
|
||||
const parts = token.split(".");
|
||||
const tamperedToken = `${parts[0]}.${parts[1]}.modified-signature`;
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: tamperedToken }
|
||||
});
|
||||
|
||||
const extractedUserId = await getUserID(event);
|
||||
expect(extractedUserId).toBeNull();
|
||||
});
|
||||
|
||||
it("should reject none algorithm JWT tokens", async () => {
|
||||
// Try to create a token with 'none' algorithm (security vulnerability)
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({ id: "attacker-id", exp: Date.now() / 1000 + 3600 })
|
||||
).toString("base64url");
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({ alg: "none", typ: "JWT" })
|
||||
).toString("base64url");
|
||||
const noneToken = `${header}.${payload}.`;
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: noneToken }
|
||||
});
|
||||
|
||||
const extractedUserId = await getUserID(event);
|
||||
expect(extractedUserId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Privilege Level Security", () => {
|
||||
it("should return admin privilege for admin user", async () => {
|
||||
const adminId = env.ADMIN_ID;
|
||||
const token = await createTestJWT(adminId);
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: token }
|
||||
});
|
||||
|
||||
const privilege = await getPrivilegeLevel(event);
|
||||
expect(privilege).toBe("admin");
|
||||
});
|
||||
|
||||
it("should return user privilege for regular user", async () => {
|
||||
const userId = "regular-user-123";
|
||||
const token = await createTestJWT(userId);
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: token }
|
||||
});
|
||||
|
||||
const privilege = await getPrivilegeLevel(event);
|
||||
expect(privilege).toBe("user");
|
||||
});
|
||||
|
||||
it("should return anonymous privilege for unauthenticated request", async () => {
|
||||
const event = createMockEvent({});
|
||||
const privilege = await getPrivilegeLevel(event);
|
||||
expect(privilege).toBe("anonymous");
|
||||
});
|
||||
|
||||
it("should return anonymous privilege for invalid token", async () => {
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: "invalid-token" }
|
||||
});
|
||||
|
||||
const privilege = await getPrivilegeLevel(event);
|
||||
expect(privilege).toBe("anonymous");
|
||||
});
|
||||
|
||||
it("should not allow privilege escalation through token manipulation", async () => {
|
||||
const userId = "regular-user-123";
|
||||
const token = await createTestJWT(userId);
|
||||
|
||||
// Even if attacker modifies the token, signature verification will fail
|
||||
const parts = token.split(".");
|
||||
const fakeAdminPayload = Buffer.from(
|
||||
JSON.stringify({ id: env.ADMIN_ID })
|
||||
).toString("base64url");
|
||||
const fakeAdminToken = `${parts[0]}.${fakeAdminPayload}.${parts[2]}`;
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: fakeAdminToken }
|
||||
});
|
||||
|
||||
const privilege = await getPrivilegeLevel(event);
|
||||
expect(privilege).toBe("anonymous"); // Token validation fails
|
||||
});
|
||||
});
|
||||
|
||||
describe("Session Management", () => {
|
||||
it("should identify authenticated sessions correctly", async () => {
|
||||
const userId = "test-user-123";
|
||||
const token = await createTestJWT(userId);
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: token }
|
||||
});
|
||||
|
||||
const authStatus = await checkAuthStatus(event);
|
||||
expect(authStatus.isAuthenticated).toBe(true);
|
||||
expect(authStatus.userId).toBe(userId);
|
||||
});
|
||||
|
||||
it("should identify unauthenticated sessions correctly", async () => {
|
||||
const event = createMockEvent({});
|
||||
const authStatus = await checkAuthStatus(event);
|
||||
|
||||
expect(authStatus.isAuthenticated).toBe(false);
|
||||
expect(authStatus.userId).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle session with expired token", async () => {
|
||||
const userId = "test-user-123";
|
||||
const expiredToken = await createExpiredJWT(userId);
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: expiredToken }
|
||||
});
|
||||
|
||||
const authStatus = await checkAuthStatus(event);
|
||||
expect(authStatus.isAuthenticated).toBe(false);
|
||||
expect(authStatus.userId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Timing Attack Prevention", () => {
|
||||
it("should have consistent timing for valid and invalid tokens", async () => {
|
||||
const userId = "test-user-123";
|
||||
const validToken = await createTestJWT(userId);
|
||||
const invalidToken = "invalid-token";
|
||||
|
||||
// Measure time for valid token
|
||||
const validEvent = createMockEvent({
|
||||
cookies: { userIDToken: validToken }
|
||||
});
|
||||
const { duration: validDuration } = await measureTime(() =>
|
||||
getUserID(validEvent)
|
||||
);
|
||||
|
||||
// Measure time for invalid token
|
||||
const invalidEvent = createMockEvent({
|
||||
cookies: { userIDToken: invalidToken }
|
||||
});
|
||||
const { duration: invalidDuration } = await measureTime(() =>
|
||||
getUserID(invalidEvent)
|
||||
);
|
||||
|
||||
// Timing difference should be minimal (within reasonable variance)
|
||||
// This helps prevent timing attacks to enumerate valid tokens
|
||||
const timingDifference = Math.abs(validDuration - invalidDuration);
|
||||
|
||||
// Allow up to 5ms variance (accounts for system variations)
|
||||
expect(timingDifference).toBeLessThan(5);
|
||||
});
|
||||
|
||||
it("should have consistent timing for different user privilege levels", async () => {
|
||||
const adminId = env.ADMIN_ID;
|
||||
const userId = "regular-user-123";
|
||||
|
||||
const adminToken = await createTestJWT(adminId);
|
||||
const userToken = await createTestJWT(userId);
|
||||
|
||||
// Measure time for admin privilege check
|
||||
const adminEvent = createMockEvent({
|
||||
cookies: { userIDToken: adminToken }
|
||||
});
|
||||
const { duration: adminDuration } = await measureTime(() =>
|
||||
getPrivilegeLevel(adminEvent)
|
||||
);
|
||||
|
||||
// Measure time for user privilege check
|
||||
const userEvent = createMockEvent({
|
||||
cookies: { userIDToken: userToken }
|
||||
});
|
||||
const { duration: userDuration } = await measureTime(() =>
|
||||
getPrivilegeLevel(userEvent)
|
||||
);
|
||||
|
||||
const timingDifference = Math.abs(adminDuration - userDuration);
|
||||
expect(timingDifference).toBeLessThan(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Expiration", () => {
|
||||
it("should respect token expiration time", async () => {
|
||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||
const userId = "test-user-123";
|
||||
|
||||
// Create token expiring in 1 second
|
||||
const shortLivedToken = await new SignJWT({ id: userId })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("1s")
|
||||
.sign(secret);
|
||||
|
||||
// Should work immediately
|
||||
const event1 = createMockEvent({
|
||||
cookies: { userIDToken: shortLivedToken }
|
||||
});
|
||||
const id1 = await getUserID(event1);
|
||||
expect(id1).toBe(userId);
|
||||
|
||||
// Wait for token to expire
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
// Should fail after expiration
|
||||
const event2 = createMockEvent({
|
||||
cookies: { userIDToken: shortLivedToken }
|
||||
});
|
||||
const id2 = await getUserID(event2);
|
||||
expect(id2).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle tokens with very long expiration", async () => {
|
||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||
const userId = "test-user-123";
|
||||
|
||||
const longLivedToken = await new SignJWT({ id: userId })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("365d") // 1 year
|
||||
.sign(secret);
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: longLivedToken }
|
||||
});
|
||||
|
||||
const extractedId = await getUserID(event);
|
||||
expect(extractedId).toBe(userId);
|
||||
});
|
||||
|
||||
it("should reject tokens with past expiration timestamps", async () => {
|
||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||
const userId = "test-user-123";
|
||||
|
||||
const pastToken = await new SignJWT({ id: userId })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime(Math.floor(Date.now() / 1000) - 3600) // 1 hour ago
|
||||
.sign(secret);
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: pastToken }
|
||||
});
|
||||
|
||||
const extractedId = await getUserID(event);
|
||||
expect(extractedId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle very long JWT tokens", async () => {
|
||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||
const largePayload = {
|
||||
id: "test-user-123",
|
||||
extraData: "x".repeat(10000) // 10KB of extra data
|
||||
};
|
||||
|
||||
const largeToken = await new SignJWT(largePayload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("1h")
|
||||
.sign(secret);
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: largeToken }
|
||||
});
|
||||
|
||||
const extractedId = await getUserID(event);
|
||||
expect(extractedId).toBe("test-user-123");
|
||||
});
|
||||
|
||||
it("should handle special characters in user IDs", async () => {
|
||||
const specialUserId = "user-with-special-!@#$%^&*()";
|
||||
const token = await createTestJWT(specialUserId);
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: token }
|
||||
});
|
||||
|
||||
const extractedId = await getUserID(event);
|
||||
expect(extractedId).toBe(specialUserId);
|
||||
});
|
||||
|
||||
it("should handle unicode user IDs", async () => {
|
||||
const unicodeUserId = "user-with-unicode-🔐🛡️";
|
||||
const token = await createTestJWT(unicodeUserId);
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: token }
|
||||
});
|
||||
|
||||
const extractedId = await getUserID(event);
|
||||
expect(extractedId).toBe(unicodeUserId);
|
||||
});
|
||||
|
||||
it("should reject JWT with future issued-at time", async () => {
|
||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||
const futureToken = await new SignJWT({ id: "test-user-123" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt(Math.floor(Date.now() / 1000) + 3600) // 1 hour in future
|
||||
.setExpirationTime("2h")
|
||||
.sign(secret);
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: futureToken }
|
||||
});
|
||||
|
||||
// Some JWT libraries reject future iat, some don't
|
||||
// This test documents the behavior
|
||||
const extractedId = await getUserID(event);
|
||||
// Behavior may vary - just ensure no crash
|
||||
expect(extractedId === null || extractedId === "test-user-123").toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance", () => {
|
||||
it("should validate tokens efficiently", async () => {
|
||||
const userId = "test-user-123";
|
||||
const token = await createTestJWT(userId);
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: token }
|
||||
});
|
||||
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
await getUserID(event);
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Should validate 1000 tokens in less than 100ms
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("should check privilege levels efficiently", async () => {
|
||||
const userId = "test-user-123";
|
||||
const token = await createTestJWT(userId);
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: token }
|
||||
});
|
||||
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
await getPrivilegeLevel(event);
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Should check 1000 privileges in less than 100ms
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
417
src/server/security/authorization.test.ts
Normal file
417
src/server/security/authorization.test.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Authorization Tests
|
||||
* Tests for access control, privilege escalation prevention, and admin access
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { getUserID, getPrivilegeLevel } from "~/server/auth";
|
||||
import { createMockEvent, createTestJWT } from "./test-utils";
|
||||
import { env } from "~/env/server";
|
||||
|
||||
describe("Authorization", () => {
|
||||
describe("Admin Access Control", () => {
|
||||
it("should grant admin access to configured admin user", async () => {
|
||||
const adminToken = await createTestJWT(env.ADMIN_ID);
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: adminToken }
|
||||
});
|
||||
|
||||
const privilege = await getPrivilegeLevel(event);
|
||||
expect(privilege).toBe("admin");
|
||||
});
|
||||
|
||||
it("should deny admin access to regular users", async () => {
|
||||
const userToken = await createTestJWT("regular-user-123");
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: userToken }
|
||||
});
|
||||
|
||||
const privilege = await getPrivilegeLevel(event);
|
||||
expect(privilege).toBe("user");
|
||||
expect(privilege).not.toBe("admin");
|
||||
});
|
||||
|
||||
it("should deny admin access to anonymous users", async () => {
|
||||
const event = createMockEvent({});
|
||||
const privilege = await getPrivilegeLevel(event);
|
||||
|
||||
expect(privilege).toBe("anonymous");
|
||||
expect(privilege).not.toBe("admin");
|
||||
});
|
||||
|
||||
it("should not allow privilege escalation through token tampering", async () => {
|
||||
// Create a regular user token
|
||||
const regularToken = await createTestJWT("regular-user-123");
|
||||
|
||||
// Attacker tries to modify token to include admin ID
|
||||
// This should fail signature verification
|
||||
const parts = regularToken.split(".");
|
||||
const fakeAdminPayload = Buffer.from(
|
||||
JSON.stringify({ id: env.ADMIN_ID })
|
||||
).toString("base64url");
|
||||
const tamperedToken = `${parts[0]}.${fakeAdminPayload}.${parts[2]}`;
|
||||
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: tamperedToken }
|
||||
});
|
||||
|
||||
const privilege = await getPrivilegeLevel(event);
|
||||
expect(privilege).toBe("anonymous"); // Invalid token = anonymous
|
||||
});
|
||||
|
||||
it("should handle malformed admin ID gracefully", async () => {
|
||||
const invalidIds = ["", null, undefined, " ", "admin'--"];
|
||||
|
||||
for (const invalidId of invalidIds) {
|
||||
const token = await createTestJWT(invalidId as string);
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: token }
|
||||
});
|
||||
|
||||
const privilege = await getPrivilegeLevel(event);
|
||||
// Should not grant admin access for invalid IDs
|
||||
expect(privilege).not.toBe("admin");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Access Control", () => {
|
||||
it("should grant user access to authenticated users", async () => {
|
||||
const userToken = await createTestJWT("user-123");
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: userToken }
|
||||
});
|
||||
|
||||
const privilege = await getPrivilegeLevel(event);
|
||||
expect(privilege).toBe("user");
|
||||
});
|
||||
|
||||
it("should deny user access to anonymous requests", async () => {
|
||||
const event = createMockEvent({});
|
||||
const privilege = await getPrivilegeLevel(event);
|
||||
|
||||
expect(privilege).toBe("anonymous");
|
||||
expect(privilege).not.toBe("user");
|
||||
});
|
||||
|
||||
it("should maintain user access with valid token", async () => {
|
||||
const userToken = await createTestJWT("user-456");
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: userToken }
|
||||
});
|
||||
|
||||
const userId = await getUserID(event);
|
||||
expect(userId).toBe("user-456");
|
||||
|
||||
const privilege = await getPrivilegeLevel(event);
|
||||
expect(privilege).toBe("user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Privilege Escalation Prevention", () => {
|
||||
it("should prevent horizontal privilege escalation", async () => {
|
||||
const user1Token = await createTestJWT("user-1");
|
||||
const user2Token = await createTestJWT("user-2");
|
||||
|
||||
const event1 = createMockEvent({
|
||||
cookies: { userIDToken: user1Token }
|
||||
});
|
||||
const event2 = createMockEvent({
|
||||
cookies: { userIDToken: user2Token }
|
||||
});
|
||||
|
||||
const user1Id = await getUserID(event1);
|
||||
const user2Id = await getUserID(event2);
|
||||
|
||||
expect(user1Id).toBe("user-1");
|
||||
expect(user2Id).toBe("user-2");
|
||||
expect(user1Id).not.toBe(user2Id);
|
||||
});
|
||||
|
||||
it("should prevent vertical privilege escalation", async () => {
|
||||
// Regular user should not be able to become admin
|
||||
const userToken = await createTestJWT("regular-user");
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: userToken }
|
||||
});
|
||||
|
||||
const privilege = await getPrivilegeLevel(event);
|
||||
expect(privilege).toBe("user");
|
||||
|
||||
// Even with multiple checks, privilege should remain the same
|
||||
const privilege2 = await getPrivilegeLevel(event);
|
||||
expect(privilege2).toBe("user");
|
||||
});
|
||||
|
||||
it("should not allow session hijacking through token reuse", async () => {
|
||||
const user1Token = await createTestJWT("user-1");
|
||||
|
||||
// User 1's token should always return user 1's ID
|
||||
const event1 = createMockEvent({
|
||||
cookies: { userIDToken: user1Token }
|
||||
});
|
||||
const id1 = await getUserID(event1);
|
||||
|
||||
// Even if attacker captures token, it still identifies as user 1
|
||||
const event2 = createMockEvent({
|
||||
cookies: { userIDToken: user1Token }
|
||||
});
|
||||
const id2 = await getUserID(event2);
|
||||
|
||||
expect(id1).toBe("user-1");
|
||||
expect(id2).toBe("user-1");
|
||||
});
|
||||
|
||||
it("should prevent privilege escalation via race conditions", async () => {
|
||||
const userToken = await createTestJWT("concurrent-user");
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: userToken }
|
||||
});
|
||||
|
||||
// Simulate concurrent privilege checks
|
||||
const results = await Promise.all([
|
||||
getPrivilegeLevel(event),
|
||||
getPrivilegeLevel(event),
|
||||
getPrivilegeLevel(event),
|
||||
getPrivilegeLevel(event),
|
||||
getPrivilegeLevel(event)
|
||||
]);
|
||||
|
||||
// All results should be the same
|
||||
expect(results.every((r) => r === "user")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Anonymous Access", () => {
|
||||
it("should handle missing authentication token", async () => {
|
||||
const event = createMockEvent({});
|
||||
const privilege = await getPrivilegeLevel(event);
|
||||
|
||||
expect(privilege).toBe("anonymous");
|
||||
});
|
||||
|
||||
it("should handle empty authentication token", async () => {
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: "" }
|
||||
});
|
||||
const privilege = await getPrivilegeLevel(event);
|
||||
|
||||
expect(privilege).toBe("anonymous");
|
||||
});
|
||||
|
||||
it("should handle invalid token format", async () => {
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: "not-a-jwt-token" }
|
||||
});
|
||||
const privilege = await getPrivilegeLevel(event);
|
||||
|
||||
expect(privilege).toBe("anonymous");
|
||||
});
|
||||
|
||||
it("should return null user ID for anonymous users", async () => {
|
||||
const event = createMockEvent({});
|
||||
const userId = await getUserID(event);
|
||||
|
||||
expect(userId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Access Control Edge Cases", () => {
|
||||
it("should handle user ID with special characters", async () => {
|
||||
const specialUserId = "user-with-special-!@#$%";
|
||||
const token = await createTestJWT(specialUserId);
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: token }
|
||||
});
|
||||
|
||||
const userId = await getUserID(event);
|
||||
expect(userId).toBe(specialUserId);
|
||||
});
|
||||
|
||||
it("should handle very long user IDs", async () => {
|
||||
const longUserId = "user-" + "x".repeat(1000);
|
||||
const token = await createTestJWT(longUserId);
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: token }
|
||||
});
|
||||
|
||||
const userId = await getUserID(event);
|
||||
expect(userId).toBe(longUserId);
|
||||
});
|
||||
|
||||
it("should handle user ID with unicode characters", async () => {
|
||||
const unicodeUserId = "user-with-unicode-🔐";
|
||||
const token = await createTestJWT(unicodeUserId);
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: token }
|
||||
});
|
||||
|
||||
const userId = await getUserID(event);
|
||||
expect(userId).toBe(unicodeUserId);
|
||||
});
|
||||
|
||||
it("should handle admin ID case sensitivity", async () => {
|
||||
const adminId = env.ADMIN_ID;
|
||||
const wrongCaseId = adminId.toUpperCase();
|
||||
|
||||
// Exact match required
|
||||
const correctToken = await createTestJWT(adminId);
|
||||
const wrongCaseToken = await createTestJWT(wrongCaseId);
|
||||
|
||||
const correctEvent = createMockEvent({
|
||||
cookies: { userIDToken: correctToken }
|
||||
});
|
||||
const wrongCaseEvent = createMockEvent({
|
||||
cookies: { userIDToken: wrongCaseToken }
|
||||
});
|
||||
|
||||
const correctPrivilege = await getPrivilegeLevel(correctEvent);
|
||||
const wrongCasePrivilege = await getPrivilegeLevel(wrongCaseEvent);
|
||||
|
||||
expect(correctPrivilege).toBe("admin");
|
||||
// Wrong case should not get admin access (unless IDs match)
|
||||
if (adminId !== wrongCaseId) {
|
||||
expect(wrongCasePrivilege).toBe("user");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authorization Attack Scenarios", () => {
|
||||
it("should prevent session fixation attacks", async () => {
|
||||
// Attacker cannot predict or fix session tokens
|
||||
const token1 = await createTestJWT("user-1");
|
||||
const token2 = await createTestJWT("user-1");
|
||||
|
||||
// Tokens should be different even for same user
|
||||
// (Due to different timestamps, though payload is same)
|
||||
expect(token1).toBeDefined();
|
||||
expect(token2).toBeDefined();
|
||||
});
|
||||
|
||||
it("should prevent parameter pollution attacks", async () => {
|
||||
// Multiple cookie values should not cause confusion
|
||||
const token1 = await createTestJWT("user-1");
|
||||
const token2 = await createTestJWT("user-2");
|
||||
|
||||
// Only first cookie should be used
|
||||
const event = createMockEvent({
|
||||
cookies: {
|
||||
userIDToken: token1
|
||||
// In practice, duplicate cookies are handled by the framework
|
||||
}
|
||||
});
|
||||
|
||||
const userId = await getUserID(event);
|
||||
expect(userId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("should prevent token substitution attacks", async () => {
|
||||
const legitimateToken = await createTestJWT("victim-user");
|
||||
const attackerToken = await createTestJWT("attacker-user");
|
||||
|
||||
// Each token should only authenticate its respective user
|
||||
const legitimateEvent = createMockEvent({
|
||||
cookies: { userIDToken: legitimateToken }
|
||||
});
|
||||
const attackerEvent = createMockEvent({
|
||||
cookies: { userIDToken: attackerToken }
|
||||
});
|
||||
|
||||
const legitimateId = await getUserID(legitimateEvent);
|
||||
const attackerId = await getUserID(attackerEvent);
|
||||
|
||||
expect(legitimateId).toBe("victim-user");
|
||||
expect(attackerId).toBe("attacker-user");
|
||||
expect(legitimateId).not.toBe(attackerId);
|
||||
});
|
||||
|
||||
it("should prevent authorization bypass through empty checks", async () => {
|
||||
const emptyChecks = [null, undefined, "", " ", "null", "undefined"];
|
||||
|
||||
for (const check of emptyChecks) {
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: check as string }
|
||||
});
|
||||
|
||||
const privilege = await getPrivilegeLevel(event);
|
||||
expect(privilege).toBe("anonymous");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multi-User Scenarios", () => {
|
||||
it("should handle multiple concurrent user sessions", async () => {
|
||||
const users = ["user-1", "user-2", "user-3", "user-4", "user-5"];
|
||||
const tokens = await Promise.all(users.map((u) => createTestJWT(u)));
|
||||
|
||||
const events = tokens.map((token) =>
|
||||
createMockEvent({ cookies: { userIDToken: token } })
|
||||
);
|
||||
|
||||
const userIds = await Promise.all(events.map(getUserID));
|
||||
|
||||
// All users should be correctly identified
|
||||
expect(userIds).toEqual(users);
|
||||
});
|
||||
|
||||
it("should maintain separate privileges for different users", async () => {
|
||||
const adminToken = await createTestJWT(env.ADMIN_ID);
|
||||
const user1Token = await createTestJWT("user-1");
|
||||
const user2Token = await createTestJWT("user-2");
|
||||
|
||||
const adminEvent = createMockEvent({
|
||||
cookies: { userIDToken: adminToken }
|
||||
});
|
||||
const user1Event = createMockEvent({
|
||||
cookies: { userIDToken: user1Token }
|
||||
});
|
||||
const user2Event = createMockEvent({
|
||||
cookies: { userIDToken: user2Token }
|
||||
});
|
||||
|
||||
const [adminPriv, user1Priv, user2Priv] = await Promise.all([
|
||||
getPrivilegeLevel(adminEvent),
|
||||
getPrivilegeLevel(user1Event),
|
||||
getPrivilegeLevel(user2Event)
|
||||
]);
|
||||
|
||||
expect(adminPriv).toBe("admin");
|
||||
expect(user1Priv).toBe("user");
|
||||
expect(user2Priv).toBe("user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance", () => {
|
||||
it("should check privileges efficiently", async () => {
|
||||
const userToken = await createTestJWT("perf-test-user");
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: userToken }
|
||||
});
|
||||
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
await getPrivilegeLevel(event);
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Should complete 1000 checks in less than 100ms
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("should extract user IDs efficiently", async () => {
|
||||
const userToken = await createTestJWT("perf-test-user");
|
||||
const event = createMockEvent({
|
||||
cookies: { userIDToken: userToken }
|
||||
});
|
||||
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
await getUserID(event);
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Should complete 1000 extractions in less than 100ms
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
320
src/server/security/csrf.test.ts
Normal file
320
src/server/security/csrf.test.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* CSRF Protection Tests
|
||||
* Tests for Cross-Site Request Forgery protection mechanisms
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "bun:test";
|
||||
import {
|
||||
generateCSRFToken,
|
||||
setCSRFToken,
|
||||
validateCSRFToken,
|
||||
csrfProtection
|
||||
} from "~/server/security";
|
||||
import { createMockEvent } from "./test-utils";
|
||||
|
||||
describe("CSRF Protection", () => {
|
||||
describe("generateCSRFToken", () => {
|
||||
it("should generate a valid UUID token", () => {
|
||||
const token = generateCSRFToken();
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
expect(token).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
);
|
||||
});
|
||||
|
||||
it("should generate unique tokens", () => {
|
||||
const token1 = generateCSRFToken();
|
||||
const token2 = generateCSRFToken();
|
||||
expect(token1).not.toBe(token2);
|
||||
});
|
||||
|
||||
it("should generate cryptographically secure tokens", () => {
|
||||
// Generate multiple tokens and ensure no collisions
|
||||
const tokens = new Set<string>();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
tokens.add(generateCSRFToken());
|
||||
}
|
||||
expect(tokens.size).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCSRFToken", () => {
|
||||
it("should set CSRF token cookie with correct attributes", () => {
|
||||
const event = createMockEvent({});
|
||||
const token = setCSRFToken(event);
|
||||
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
// Token should be a UUID
|
||||
expect(token).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
);
|
||||
});
|
||||
|
||||
it("should generate different tokens on subsequent calls", () => {
|
||||
const event1 = createMockEvent({});
|
||||
const event2 = createMockEvent({});
|
||||
|
||||
const token1 = setCSRFToken(event1);
|
||||
const token2 = setCSRFToken(event2);
|
||||
|
||||
expect(token1).not.toBe(token2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCSRFToken", () => {
|
||||
it("should validate matching tokens", () => {
|
||||
const token = generateCSRFToken();
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": token },
|
||||
cookies: { "csrf-token": token }
|
||||
});
|
||||
|
||||
const isValid = validateCSRFToken(event);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject mismatched tokens", () => {
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": "token1" },
|
||||
cookies: { "csrf-token": "token2" }
|
||||
});
|
||||
|
||||
const isValid = validateCSRFToken(event);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject missing header token", () => {
|
||||
const event = createMockEvent({
|
||||
cookies: { "csrf-token": "token" }
|
||||
});
|
||||
|
||||
const isValid = validateCSRFToken(event);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject missing cookie token", () => {
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": "token" }
|
||||
});
|
||||
|
||||
const isValid = validateCSRFToken(event);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject empty tokens", () => {
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": "" },
|
||||
cookies: { "csrf-token": "" }
|
||||
});
|
||||
|
||||
const isValid = validateCSRFToken(event);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should use constant-time comparison", async () => {
|
||||
const validToken = "a".repeat(36);
|
||||
const invalidToken1 = "b".repeat(36);
|
||||
const invalidToken2 = "b".repeat(35) + "a";
|
||||
|
||||
// Test timing for completely different tokens
|
||||
const event1 = createMockEvent({
|
||||
headers: { "x-csrf-token": invalidToken1 },
|
||||
cookies: { "csrf-token": validToken }
|
||||
});
|
||||
|
||||
const start1 = performance.now();
|
||||
validateCSRFToken(event1);
|
||||
const time1 = performance.now() - start1;
|
||||
|
||||
// Test timing for tokens that differ only at the end
|
||||
const event2 = createMockEvent({
|
||||
headers: { "x-csrf-token": invalidToken2 },
|
||||
cookies: { "csrf-token": validToken }
|
||||
});
|
||||
|
||||
const start2 = performance.now();
|
||||
validateCSRFToken(event2);
|
||||
const time2 = performance.now() - start2;
|
||||
|
||||
// Timing difference should be minimal (less than 1ms)
|
||||
// This tests for constant-time comparison
|
||||
const timeDiff = Math.abs(time1 - time2);
|
||||
expect(timeDiff).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("should reject tokens with different lengths", () => {
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": "short" },
|
||||
cookies: { "csrf-token": "much-longer-token" }
|
||||
});
|
||||
|
||||
const isValid = validateCSRFToken(event);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSRF Attack Scenarios", () => {
|
||||
it("should prevent basic CSRF attack", () => {
|
||||
// Attacker doesn't have access to the CSRF token cookie
|
||||
const attackEvent = createMockEvent({
|
||||
headers: { "x-csrf-token": "attacker-guessed-token" }
|
||||
});
|
||||
|
||||
const isValid = validateCSRFToken(attackEvent);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should prevent token reuse from different session", () => {
|
||||
const token1 = generateCSRFToken();
|
||||
const token2 = generateCSRFToken();
|
||||
|
||||
// User has token1, attacker tries to use token2
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": token2 },
|
||||
cookies: { "csrf-token": token1 }
|
||||
});
|
||||
|
||||
const isValid = validateCSRFToken(event);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should prevent token modification", () => {
|
||||
const token = generateCSRFToken();
|
||||
const modifiedToken = token.slice(0, -1) + "x";
|
||||
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": modifiedToken },
|
||||
cookies: { "csrf-token": token }
|
||||
});
|
||||
|
||||
const isValid = validateCSRFToken(event);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should prevent replay attacks with old tokens", () => {
|
||||
// Simulate an old token that was captured
|
||||
const oldToken = "old-captured-token-12345";
|
||||
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": oldToken },
|
||||
cookies: { "csrf-token": oldToken }
|
||||
});
|
||||
|
||||
// Even if tokens match, they should be validated by the system
|
||||
// This test validates the structure works correctly
|
||||
const isValid = validateCSRFToken(event);
|
||||
expect(isValid).toBe(true); // Matches are valid
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle null tokens", () => {
|
||||
const event = createMockEvent({});
|
||||
const isValid = validateCSRFToken(event);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle undefined tokens", () => {
|
||||
const event = createMockEvent({
|
||||
headers: {},
|
||||
cookies: {}
|
||||
});
|
||||
const isValid = validateCSRFToken(event);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle special characters in tokens", () => {
|
||||
const token = "token-with-special-!@#$%^&*()";
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": token },
|
||||
cookies: { "csrf-token": token }
|
||||
});
|
||||
|
||||
const isValid = validateCSRFToken(event);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle very long tokens", () => {
|
||||
const longToken = "a".repeat(1000);
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": longToken },
|
||||
cookies: { "csrf-token": longToken }
|
||||
});
|
||||
|
||||
const isValid = validateCSRFToken(event);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle unicode tokens", () => {
|
||||
const unicodeToken = "token-with-unicode-🔒🛡️";
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": unicodeToken },
|
||||
cookies: { "csrf-token": unicodeToken }
|
||||
});
|
||||
|
||||
const isValid = validateCSRFToken(event);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Generation Security", () => {
|
||||
it("should not generate predictable tokens", () => {
|
||||
const tokens: string[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
tokens.push(generateCSRFToken());
|
||||
}
|
||||
|
||||
// Check for sequential patterns
|
||||
for (let i = 1; i < tokens.length; i++) {
|
||||
// Tokens should not be incrementing
|
||||
expect(tokens[i]).not.toBe(
|
||||
String(Number(tokens[i - 1].replace(/-/g, "")) + 1)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should generate tokens with sufficient entropy", () => {
|
||||
const token = generateCSRFToken();
|
||||
// UUID without dashes should be 32 hex characters
|
||||
const hexString = token.replace(/-/g, "");
|
||||
expect(hexString).toMatch(/^[0-9a-f]{32}$/i);
|
||||
|
||||
// Check that not all characters are the same
|
||||
const uniqueChars = new Set(hexString.split(""));
|
||||
expect(uniqueChars.size).toBeGreaterThan(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance", () => {
|
||||
it("should generate tokens quickly", () => {
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
generateCSRFToken();
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Should generate 1000 tokens in less than 100ms
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("should validate tokens quickly", () => {
|
||||
const token = generateCSRFToken();
|
||||
const event = createMockEvent({
|
||||
headers: { "x-csrf-token": token },
|
||||
cookies: { "csrf-token": token }
|
||||
});
|
||||
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
validateCSRFToken(event);
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Should validate 10000 tokens in less than 100ms
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
522
src/server/security/injection.test.ts
Normal file
522
src/server/security/injection.test.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* Input Validation and Injection Tests
|
||||
* Tests for SQL injection, XSS, and other injection attack prevention
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
isValidEmail,
|
||||
validatePassword,
|
||||
isValidDisplayName
|
||||
} from "~/lib/validation";
|
||||
import { SQL_INJECTION_PAYLOADS, XSS_PAYLOADS } from "./test-utils";
|
||||
import { ConnectionFactory } from "~/server/database";
|
||||
|
||||
describe("Input Validation and Injection Prevention", () => {
|
||||
describe("Email Validation", () => {
|
||||
it("should accept valid email addresses", () => {
|
||||
const validEmails = [
|
||||
"user@example.com",
|
||||
"test.user@example.com",
|
||||
"user+tag@example.co.uk",
|
||||
"user123@test-domain.com",
|
||||
"first.last@subdomain.example.com"
|
||||
];
|
||||
|
||||
for (const email of validEmails) {
|
||||
expect(isValidEmail(email)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject invalid email addresses", () => {
|
||||
const invalidEmails = [
|
||||
"not-an-email",
|
||||
"@example.com",
|
||||
"user@",
|
||||
"user @example.com",
|
||||
"user@example",
|
||||
"user..name@example.com",
|
||||
"user@.com",
|
||||
"",
|
||||
" ",
|
||||
"user@domain@domain.com"
|
||||
];
|
||||
|
||||
for (const email of invalidEmails) {
|
||||
expect(isValidEmail(email)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject SQL injection attempts in emails", () => {
|
||||
const sqlEmails = [
|
||||
"admin'--@example.com",
|
||||
"user@example.com'; DROP TABLE User--",
|
||||
"' OR '1'='1@example.com",
|
||||
"user@example.com' UNION SELECT",
|
||||
"admin@example.com'--"
|
||||
];
|
||||
|
||||
for (const email of sqlEmails) {
|
||||
// Either reject as invalid, or it's properly escaped in queries
|
||||
const isValid = isValidEmail(email);
|
||||
// Test documents the behavior
|
||||
expect(typeof isValid).toBe("boolean");
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle very long email addresses", () => {
|
||||
const longEmail = "a".repeat(1000) + "@example.com";
|
||||
const result = isValidEmail(longEmail);
|
||||
|
||||
// Should handle gracefully
|
||||
expect(typeof result).toBe("boolean");
|
||||
});
|
||||
|
||||
it("should handle email with unicode characters", () => {
|
||||
const unicodeEmail = "üser@exämple.com";
|
||||
const result = isValidEmail(unicodeEmail);
|
||||
|
||||
expect(typeof result).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Display Name Validation", () => {
|
||||
it("should accept valid display names", () => {
|
||||
const validNames = [
|
||||
"John Doe",
|
||||
"Alice",
|
||||
"Bob Smith Jr.",
|
||||
"李明",
|
||||
"José García",
|
||||
"123User"
|
||||
];
|
||||
|
||||
for (const name of validNames) {
|
||||
expect(isValidDisplayName(name)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject empty display names", () => {
|
||||
const invalidNames = ["", " ", "\t", "\n"];
|
||||
|
||||
for (const name of invalidNames) {
|
||||
expect(isValidDisplayName(name)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject excessively long display names", () => {
|
||||
const longName = "a".repeat(51);
|
||||
expect(isValidDisplayName(longName)).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle display names with special characters", () => {
|
||||
const specialNames = [
|
||||
"User<script>",
|
||||
"User'--",
|
||||
'User"OR"1"="1',
|
||||
"User & Co",
|
||||
"User@123"
|
||||
];
|
||||
|
||||
for (const name of specialNames) {
|
||||
const isValid = isValidDisplayName(name);
|
||||
// Should either accept and sanitize, or reject
|
||||
expect(typeof isValid).toBe("boolean");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQL Injection Prevention", () => {
|
||||
it("should use parameterized queries for user authentication", async () => {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Test that SQL injection attempts don't work
|
||||
const maliciousEmail = "admin'--";
|
||||
|
||||
try {
|
||||
// This query uses parameterized args (safe)
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT * FROM User WHERE email = ?",
|
||||
args: [maliciousEmail]
|
||||
});
|
||||
|
||||
// Should return no results (no user with that exact email)
|
||||
expect(result.rows.length).toBe(0);
|
||||
} catch (error) {
|
||||
// If error, ensure it's not a SQL error
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("should prevent SQL injection in all query parameters", async () => {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
for (const payload of SQL_INJECTION_PAYLOADS) {
|
||||
try {
|
||||
// Test various injection points
|
||||
await conn.execute({
|
||||
sql: "SELECT * FROM User WHERE email = ?",
|
||||
args: [payload]
|
||||
});
|
||||
|
||||
await conn.execute({
|
||||
sql: "SELECT * FROM User WHERE display_name = ?",
|
||||
args: [payload]
|
||||
});
|
||||
|
||||
// Queries should complete without SQL errors
|
||||
expect(true).toBe(true);
|
||||
} catch (error: any) {
|
||||
// If error occurs, should not be SQL injection syntax error
|
||||
expect(error.message).not.toContain("syntax error");
|
||||
expect(error.message).not.toContain("SQL");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should prevent UNION-based SQL injection", async () => {
|
||||
const conn = ConnectionFactory();
|
||||
const unionPayload = "' UNION SELECT password_hash FROM User--";
|
||||
|
||||
try {
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT email FROM User WHERE email = ?",
|
||||
args: [unionPayload]
|
||||
});
|
||||
|
||||
// Should not return password hashes
|
||||
if (result.rows.length > 0) {
|
||||
for (const row of result.rows) {
|
||||
// Ensure we don't get password_hash column
|
||||
expect(row).not.toHaveProperty("password_hash");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Error is acceptable, SQL injection is not
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("should prevent blind SQL injection timing attacks", async () => {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Timing-based payload
|
||||
const timingPayload = "admin' AND SLEEP(5)--";
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
await conn.execute({
|
||||
sql: "SELECT * FROM User WHERE email = ?",
|
||||
args: [timingPayload]
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore errors
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Should not delay for 5 seconds
|
||||
expect(duration).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it("should prevent second-order SQL injection", async () => {
|
||||
const conn = ConnectionFactory();
|
||||
|
||||
// Store malicious data
|
||||
const maliciousName = "admin'--";
|
||||
|
||||
try {
|
||||
// Insert with parameterized query (safe)
|
||||
await conn.execute({
|
||||
sql: "INSERT INTO User (id, email, display_name, provider) VALUES (?, ?, ?, ?)",
|
||||
args: [
|
||||
"test-user-sqli",
|
||||
"test-sqli@example.com",
|
||||
maliciousName,
|
||||
"email"
|
||||
]
|
||||
});
|
||||
|
||||
// Retrieve and use (should still be safe with parameterized queries)
|
||||
const result = await conn.execute({
|
||||
sql: "SELECT display_name FROM User WHERE email = ?",
|
||||
args: ["test-sqli@example.com"]
|
||||
});
|
||||
|
||||
expect(result.rows.length).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Cleanup
|
||||
await conn.execute({
|
||||
sql: "DELETE FROM User WHERE email = ?",
|
||||
args: ["test-sqli@example.com"]
|
||||
});
|
||||
} catch (error) {
|
||||
// Should not have SQL syntax errors
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("XSS Prevention", () => {
|
||||
it("should identify potentially dangerous XSS patterns", () => {
|
||||
// These payloads should be handled by frontend sanitization
|
||||
for (const payload of XSS_PAYLOADS) {
|
||||
// Document that these patterns exist
|
||||
expect(payload).toBeDefined();
|
||||
expect(typeof payload).toBe("string");
|
||||
|
||||
// In practice, these should be sanitized before rendering
|
||||
// or stored as-is and sanitized on output
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle script tags in user input", () => {
|
||||
const scriptInput = "<script>alert('XSS')</script>";
|
||||
|
||||
// Validation should not crash
|
||||
const nameValid = isValidDisplayName(scriptInput);
|
||||
expect(typeof nameValid).toBe("boolean");
|
||||
|
||||
// Email validation
|
||||
const emailValid = isValidEmail(scriptInput);
|
||||
expect(typeof emailValid).toBe("boolean");
|
||||
});
|
||||
|
||||
it("should handle event handler attributes", () => {
|
||||
const eventHandlers = [
|
||||
"onclick=alert('XSS')",
|
||||
"onerror=alert('XSS')",
|
||||
"onload=alert('XSS')",
|
||||
"onfocus=alert('XSS')"
|
||||
];
|
||||
|
||||
for (const handler of eventHandlers) {
|
||||
const result = isValidDisplayName(handler);
|
||||
expect(typeof result).toBe("boolean");
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle javascript: protocol", () => {
|
||||
const jsProtocol = "javascript:alert('XSS')";
|
||||
|
||||
const displayNameValid = isValidDisplayName(jsProtocol);
|
||||
const emailValid = isValidEmail(jsProtocol);
|
||||
|
||||
expect(typeof displayNameValid).toBe("boolean");
|
||||
expect(typeof emailValid).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Command Injection Prevention", () => {
|
||||
it("should not execute shell commands from user input", () => {
|
||||
const commandPayloads = [
|
||||
"; ls -la",
|
||||
"| cat /etc/passwd",
|
||||
"&& rm -rf /",
|
||||
"`whoami`",
|
||||
"$(whoami)",
|
||||
"; DROP TABLE User;--"
|
||||
];
|
||||
|
||||
for (const payload of commandPayloads) {
|
||||
// These should be treated as strings, not executed
|
||||
const emailValid = isValidEmail(payload);
|
||||
const nameValid = isValidDisplayName(payload);
|
||||
|
||||
expect(typeof emailValid).toBe("boolean");
|
||||
expect(typeof nameValid).toBe("boolean");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Path Traversal Prevention", () => {
|
||||
it("should not allow directory traversal in inputs", () => {
|
||||
const traversalPayloads = [
|
||||
"../../../etc/passwd",
|
||||
"..\\..\\..\\windows\\system32",
|
||||
"....//....//....//etc/passwd",
|
||||
"%2e%2e%2f",
|
||||
"..;/..;/"
|
||||
];
|
||||
|
||||
for (const payload of traversalPayloads) {
|
||||
const emailValid = isValidEmail(payload);
|
||||
const nameValid = isValidDisplayName(payload);
|
||||
|
||||
expect(typeof emailValid).toBe("boolean");
|
||||
expect(typeof nameValid).toBe("boolean");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("LDAP Injection Prevention", () => {
|
||||
it("should handle LDAP injection patterns", () => {
|
||||
const ldapPayloads = [
|
||||
"*)(uid=*))(|(uid=*",
|
||||
"admin*",
|
||||
"*)(&(password=*))",
|
||||
"*))%00"
|
||||
];
|
||||
|
||||
for (const payload of ldapPayloads) {
|
||||
const emailValid = isValidEmail(payload);
|
||||
const nameValid = isValidDisplayName(payload);
|
||||
|
||||
expect(typeof emailValid).toBe("boolean");
|
||||
expect(typeof nameValid).toBe("boolean");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("XML Injection Prevention", () => {
|
||||
it("should handle XML special characters", () => {
|
||||
const xmlPayloads = [
|
||||
"<![CDATA[attack]]>",
|
||||
'<?xml version="1.0"?>',
|
||||
'<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>',
|
||||
"<script>alert('XSS')</script>"
|
||||
];
|
||||
|
||||
for (const payload of xmlPayloads) {
|
||||
const emailValid = isValidEmail(payload);
|
||||
const nameValid = isValidDisplayName(payload);
|
||||
|
||||
expect(typeof emailValid).toBe("boolean");
|
||||
expect(typeof nameValid).toBe("boolean");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("NoSQL Injection Prevention", () => {
|
||||
it("should handle MongoDB-style injection attempts", () => {
|
||||
const nosqlPayloads = [
|
||||
'{"$gt": ""}',
|
||||
'{"$ne": null}',
|
||||
'{"$regex": ".*"}',
|
||||
'{"$where": "sleep(1000)"}',
|
||||
'{"username": {"$gt": ""}}'
|
||||
];
|
||||
|
||||
for (const payload of nosqlPayloads) {
|
||||
const emailValid = isValidEmail(payload);
|
||||
const nameValid = isValidDisplayName(payload);
|
||||
|
||||
expect(typeof emailValid).toBe("boolean");
|
||||
expect(typeof nameValid).toBe("boolean");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Header Injection Prevention", () => {
|
||||
it("should reject inputs with newline characters", () => {
|
||||
const headerInjection = [
|
||||
"user@example.com\r\nBcc: attacker@evil.com",
|
||||
"test\nSet-Cookie: admin=true",
|
||||
"user\r\nLocation: http://evil.com"
|
||||
];
|
||||
|
||||
for (const payload of headerInjection) {
|
||||
const emailValid = isValidEmail(payload);
|
||||
expect(emailValid).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Null Byte Injection Prevention", () => {
|
||||
it("should handle null bytes in input", () => {
|
||||
const nullBytePayloads = [
|
||||
"admin\x00.jpg",
|
||||
"user@example.com\x00admin",
|
||||
"test\0injection"
|
||||
];
|
||||
|
||||
for (const payload of nullBytePayloads) {
|
||||
const emailValid = isValidEmail(payload);
|
||||
const nameValid = isValidDisplayName(payload);
|
||||
|
||||
expect(typeof emailValid).toBe("boolean");
|
||||
expect(typeof nameValid).toBe("boolean");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle extremely long inputs", () => {
|
||||
const longInput = "a".repeat(100000);
|
||||
|
||||
const start = performance.now();
|
||||
const emailValid = isValidEmail(longInput);
|
||||
const nameValid = isValidDisplayName(longInput);
|
||||
const duration = performance.now() - start;
|
||||
|
||||
expect(typeof emailValid).toBe("boolean");
|
||||
expect(typeof nameValid).toBe("boolean");
|
||||
// Should complete quickly (no ReDoS)
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("should handle repeated characters", () => {
|
||||
const repeated = "a".repeat(10000) + "@example.com";
|
||||
const emailValid = isValidEmail(repeated);
|
||||
|
||||
expect(typeof emailValid).toBe("boolean");
|
||||
});
|
||||
|
||||
it("should handle mixed encoding", () => {
|
||||
const mixedEncoding = "test%40example.com";
|
||||
const emailValid = isValidEmail(mixedEncoding);
|
||||
|
||||
expect(typeof emailValid).toBe("boolean");
|
||||
});
|
||||
|
||||
it("should handle unicode normalization issues", () => {
|
||||
const unicodePayloads = [
|
||||
"admin\u0041", // 'A' in unicode
|
||||
"test\u200B@example.com", // zero-width space
|
||||
"user\uFEFF@example.com" // zero-width no-break space
|
||||
];
|
||||
|
||||
for (const payload of unicodePayloads) {
|
||||
const emailValid = isValidEmail(payload);
|
||||
expect(typeof emailValid).toBe("boolean");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance", () => {
|
||||
it("should validate emails efficiently", () => {
|
||||
const email = "test@example.com";
|
||||
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
isValidEmail(email);
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("should validate display names efficiently", () => {
|
||||
const name = "Test User";
|
||||
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
isValidDisplayName(name);
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("should not be vulnerable to ReDoS attacks", () => {
|
||||
// ReDoS payload with many repetitions
|
||||
const redosPayload = "a".repeat(1000) + "!";
|
||||
|
||||
const start = performance.now();
|
||||
validatePassword(redosPayload);
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Should complete quickly
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
529
src/server/security/password.test.ts
Normal file
529
src/server/security/password.test.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
/**
|
||||
* Password Security Tests
|
||||
* Tests for password hashing, validation, strength requirements, and timing attacks
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
hashPassword,
|
||||
checkPassword,
|
||||
checkPasswordSafe
|
||||
} from "~/server/password";
|
||||
import { validatePassword, passwordsMatch } from "~/lib/validation";
|
||||
import { measureTime } from "./test-utils";
|
||||
|
||||
describe("Password Security", () => {
|
||||
describe("Password Hashing", () => {
|
||||
it("should hash passwords using bcrypt", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
expect(hash).toBeDefined();
|
||||
expect(typeof hash).toBe("string");
|
||||
// Bcrypt hashes start with $2b$ or $2a$
|
||||
expect(hash).toMatch(/^\$2[ab]\$/);
|
||||
});
|
||||
|
||||
it("should generate unique hashes for same password", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash1 = await hashPassword(password);
|
||||
const hash2 = await hashPassword(password);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it("should generate hashes with sufficient length", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
// Bcrypt hashes are 60 characters long
|
||||
expect(hash.length).toBe(60);
|
||||
});
|
||||
|
||||
it("should handle very long passwords", async () => {
|
||||
const longPassword = "a".repeat(1000);
|
||||
const hash = await hashPassword(longPassword);
|
||||
|
||||
expect(hash).toBeDefined();
|
||||
expect(hash.length).toBe(60);
|
||||
});
|
||||
|
||||
it("should handle passwords with special characters", async () => {
|
||||
const specialPassword = "P@ssw0rd!#$%^&*()_+-=[]{}|;:',.<>?/~`";
|
||||
const hash = await hashPassword(specialPassword);
|
||||
|
||||
expect(hash).toBeDefined();
|
||||
const match = await checkPassword(specialPassword, hash);
|
||||
expect(match).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle passwords with unicode characters", async () => {
|
||||
const unicodePassword = "Pässwörd123🔐🛡️";
|
||||
const hash = await hashPassword(unicodePassword);
|
||||
|
||||
expect(hash).toBeDefined();
|
||||
const match = await checkPassword(unicodePassword, hash);
|
||||
expect(match).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle empty passwords", async () => {
|
||||
const emptyPassword = "";
|
||||
const hash = await hashPassword(emptyPassword);
|
||||
|
||||
expect(hash).toBeDefined();
|
||||
expect(hash.length).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Password Verification", () => {
|
||||
it("should verify correct password", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
const match = await checkPassword(password, hash);
|
||||
|
||||
expect(match).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject incorrect password", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const wrongPassword = "WrongPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
const match = await checkPassword(wrongPassword, hash);
|
||||
|
||||
expect(match).toBe(false);
|
||||
});
|
||||
|
||||
it("should be case-sensitive", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
const match = await checkPassword("testpassword123!", hash);
|
||||
|
||||
expect(match).toBe(false);
|
||||
});
|
||||
|
||||
it("should detect single character differences", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
const almostMatch = "TestPassword124!";
|
||||
const match = await checkPassword(almostMatch, hash);
|
||||
|
||||
expect(match).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject password with extra characters", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
const match = await checkPassword(password + "x", hash);
|
||||
|
||||
expect(match).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject password missing characters", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
const match = await checkPassword(password.slice(0, -1), hash);
|
||||
|
||||
expect(match).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Timing Attack Prevention", () => {
|
||||
it("should use constant time comparison in checkPasswordSafe", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
// Measure time for correct password
|
||||
const { duration: correctDuration } = await measureTime(() =>
|
||||
checkPasswordSafe(password, hash)
|
||||
);
|
||||
|
||||
// Measure time for incorrect password
|
||||
const { duration: incorrectDuration } = await measureTime(() =>
|
||||
checkPasswordSafe("WrongPassword123!", hash)
|
||||
);
|
||||
|
||||
// Bcrypt comparison should take similar time regardless
|
||||
const timingDifference = Math.abs(correctDuration - incorrectDuration);
|
||||
|
||||
// Allow reasonable variance (bcrypt is inherently slow)
|
||||
expect(timingDifference).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it("should handle null hash without timing leak", async () => {
|
||||
const password = "TestPassword123!";
|
||||
|
||||
// Measure time for null hash
|
||||
const { result: result1, duration: duration1 } = await measureTime(() =>
|
||||
checkPasswordSafe(password, null)
|
||||
);
|
||||
|
||||
// Measure time for undefined hash
|
||||
const { result: result2, duration: duration2 } = await measureTime(() =>
|
||||
checkPasswordSafe(password, undefined)
|
||||
);
|
||||
|
||||
expect(result1).toBe(false);
|
||||
expect(result2).toBe(false);
|
||||
|
||||
// Should take similar time
|
||||
const timingDifference = Math.abs(duration1 - duration2);
|
||||
expect(timingDifference).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it("should run bcrypt even when user doesn't exist", async () => {
|
||||
const password = "TestPassword123!";
|
||||
|
||||
// checkPasswordSafe should always run bcrypt to prevent timing attacks
|
||||
const { duration } = await measureTime(() =>
|
||||
checkPasswordSafe(password, null)
|
||||
);
|
||||
|
||||
// Should take at least a few milliseconds (bcrypt is slow)
|
||||
expect(duration).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("should have consistent timing for user exists vs not exists", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
// User exists
|
||||
const { duration: existsDuration } = await measureTime(() =>
|
||||
checkPasswordSafe("WrongPassword", hash)
|
||||
);
|
||||
|
||||
// User doesn't exist (null hash)
|
||||
const { duration: notExistsDuration } = await measureTime(() =>
|
||||
checkPasswordSafe("WrongPassword", null)
|
||||
);
|
||||
|
||||
// Timing should be similar to prevent user enumeration
|
||||
const timingDifference = Math.abs(existsDuration - notExistsDuration);
|
||||
expect(timingDifference).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Password Validation", () => {
|
||||
it("should accept strong passwords", () => {
|
||||
const strongPassword = "MyStr0ng!P@ssw0rd";
|
||||
const result = validatePassword(strongPassword);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.strength).toBe("good");
|
||||
});
|
||||
|
||||
it("should reject passwords shorter than 12 characters", () => {
|
||||
const shortPassword = "Short1!";
|
||||
const result = validatePassword(shortPassword);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
"Password must be at least 12 characters long"
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject passwords without uppercase letters", () => {
|
||||
const noUppercase = "lowercase123!@#";
|
||||
const result = validatePassword(noUppercase);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
"Password must contain at least one uppercase letter"
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject passwords without lowercase letters", () => {
|
||||
const noLowercase = "UPPERCASE123!@#";
|
||||
const result = validatePassword(noLowercase);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
"Password must contain at least one lowercase letter"
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject passwords without numbers", () => {
|
||||
const noNumbers = "NoNumbersHere!@#";
|
||||
const result = validatePassword(noNumbers);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
"Password must contain at least one number"
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject passwords without special characters", () => {
|
||||
const noSpecial = "NoSpecialChars123";
|
||||
const result = validatePassword(noSpecial);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
"Password must contain at least one special character"
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject common weak passwords", () => {
|
||||
const commonPatterns = [
|
||||
"Password123!",
|
||||
"Qwerty123456!",
|
||||
"Letmein12345!",
|
||||
"Welcome123!@"
|
||||
];
|
||||
|
||||
for (const password of commonPatterns) {
|
||||
const result = validatePassword(password);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("common patterns"))).toBe(
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should calculate password strength correctly", () => {
|
||||
const fairPassword = "MyP@ssw0rd12"; // 12 chars
|
||||
const goodPassword = "MyStr0ng!P@ssw0rd"; // 17 chars
|
||||
const strongPassword = "MyV3ry!Str0ng@P@ssw0rd123"; // 25 chars
|
||||
|
||||
expect(validatePassword(fairPassword).strength).toBe("fair");
|
||||
expect(validatePassword(goodPassword).strength).toBe("good");
|
||||
expect(validatePassword(strongPassword).strength).toBe("strong");
|
||||
});
|
||||
|
||||
it("should mark weak passwords appropriately", () => {
|
||||
const weakPassword = "weak";
|
||||
const result = validatePassword(weakPassword);
|
||||
|
||||
expect(result.strength).toBe("weak");
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Password Matching", () => {
|
||||
it("should confirm matching passwords", () => {
|
||||
const password = "TestPassword123!";
|
||||
const confirmation = "TestPassword123!";
|
||||
|
||||
expect(passwordsMatch(password, confirmation)).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject non-matching passwords", () => {
|
||||
const password = "TestPassword123!";
|
||||
const confirmation = "DifferentPassword123!";
|
||||
|
||||
expect(passwordsMatch(password, confirmation)).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject empty passwords", () => {
|
||||
expect(passwordsMatch("", "")).toBe(false);
|
||||
});
|
||||
|
||||
it("should be case-sensitive", () => {
|
||||
const password = "TestPassword123!";
|
||||
const confirmation = "testpassword123!";
|
||||
|
||||
expect(passwordsMatch(password, confirmation)).toBe(false);
|
||||
});
|
||||
|
||||
it("should detect single character differences", () => {
|
||||
const password = "TestPassword123!";
|
||||
const confirmation = "TestPassword124!";
|
||||
|
||||
expect(passwordsMatch(password, confirmation)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Password Attack Scenarios", () => {
|
||||
it("should resist brute force attacks with bcrypt slowness", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
// Measure time for multiple checks (simulating brute force)
|
||||
const start = performance.now();
|
||||
const attempts = 10;
|
||||
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
await checkPassword(`attempt${i}`, hash);
|
||||
}
|
||||
|
||||
const duration = performance.now() - start;
|
||||
const avgPerAttempt = duration / attempts;
|
||||
|
||||
// Each attempt should take significant time (bcrypt is slow)
|
||||
// This makes brute force impractical
|
||||
expect(avgPerAttempt).toBeGreaterThan(5); // At least 5ms per attempt
|
||||
});
|
||||
|
||||
it("should prevent rainbow table attacks with unique salts", async () => {
|
||||
const password = "CommonPassword123!";
|
||||
|
||||
// Generate multiple hashes for same password
|
||||
const hashes = await Promise.all(
|
||||
Array.from({ length: 10 }, () => hashPassword(password))
|
||||
);
|
||||
|
||||
// All hashes should be unique (different salts)
|
||||
const uniqueHashes = new Set(hashes);
|
||||
expect(uniqueHashes.size).toBe(10);
|
||||
});
|
||||
|
||||
it("should prevent password spraying with validation", () => {
|
||||
// Common passwords that should be rejected
|
||||
const commonPasswords = [
|
||||
"Password123!",
|
||||
"Welcome123!",
|
||||
"Admin123!@#",
|
||||
"Letmein123!"
|
||||
];
|
||||
|
||||
for (const password of commonPasswords) {
|
||||
const result = validatePassword(password);
|
||||
expect(result.isValid).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("should resist dictionary attacks", () => {
|
||||
// Dictionary words that should be caught
|
||||
const dictionaryBased = ["Sunshine123!", "Princess456!", "Dragon789!@"];
|
||||
|
||||
for (const password of dictionaryBased) {
|
||||
const result = validatePassword(password);
|
||||
expect(result.isValid).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle very long passwords", async () => {
|
||||
const longPassword = "A1!a" + "x".repeat(1000); // Very long but valid
|
||||
const hash = await hashPassword(longPassword);
|
||||
const match = await checkPassword(longPassword, hash);
|
||||
|
||||
expect(match).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle passwords with only whitespace", async () => {
|
||||
const whitespacePassword = " ";
|
||||
const result = validatePassword(whitespacePassword);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle null bytes in passwords", async () => {
|
||||
const nullBytePassword = "Test\0Password123!";
|
||||
const hash = await hashPassword(nullBytePassword);
|
||||
const match = await checkPassword(nullBytePassword, hash);
|
||||
|
||||
// Behavior may vary - just ensure no crash
|
||||
expect(typeof match).toBe("boolean");
|
||||
});
|
||||
|
||||
it("should handle passwords with emoji", () => {
|
||||
const emojiPassword = "MyP@ssw0rd🔐🛡️123";
|
||||
const result = validatePassword(emojiPassword);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle passwords with newlines", async () => {
|
||||
const newlinePassword = "Test\nPassword123!";
|
||||
const hash = await hashPassword(newlinePassword);
|
||||
const match = await checkPassword(newlinePassword, hash);
|
||||
|
||||
expect(match).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle passwords with tabs", async () => {
|
||||
const tabPassword = "Test\tPassword123!";
|
||||
const hash = await hashPassword(tabPassword);
|
||||
const match = await checkPassword(tabPassword, hash);
|
||||
|
||||
expect(match).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance", () => {
|
||||
it("should hash passwords with appropriate slowness", async () => {
|
||||
const password = "TestPassword123!";
|
||||
|
||||
const start = performance.now();
|
||||
await hashPassword(password);
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Bcrypt should be slow enough to deter brute force
|
||||
// With 10 rounds, should take at least a few milliseconds
|
||||
expect(duration).toBeGreaterThan(5);
|
||||
// But not too slow for normal operation
|
||||
expect(duration).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it("should verify passwords with consistent timing", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
const durations: number[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const start = performance.now();
|
||||
await checkPassword(password, hash);
|
||||
durations.push(performance.now() - start);
|
||||
}
|
||||
|
||||
// Timing should be relatively consistent
|
||||
const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
|
||||
const maxDeviation = Math.max(...durations.map((d) => Math.abs(d - avg)));
|
||||
|
||||
// Allow reasonable variance
|
||||
expect(maxDeviation).toBeLessThan(avg * 0.5);
|
||||
});
|
||||
|
||||
it("should validate passwords quickly", () => {
|
||||
const password = "TestPassword123!";
|
||||
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
validatePassword(password);
|
||||
}
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Validation is CPU-bound but should be fast
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bcrypt Salt Rounds", () => {
|
||||
it("should use appropriate salt rounds for security", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
// Check that hash uses correct salt rounds
|
||||
// Bcrypt format: $2b$rounds$salthash
|
||||
const parts = hash.split("$");
|
||||
const rounds = parseInt(parts[2]);
|
||||
|
||||
// Should use 10 rounds (from password.ts)
|
||||
expect(rounds).toBe(10);
|
||||
});
|
||||
|
||||
it("should generate cryptographically random salts", async () => {
|
||||
const password = "TestPassword123!";
|
||||
const hashes = await Promise.all(
|
||||
Array.from({ length: 100 }, () => hashPassword(password))
|
||||
);
|
||||
|
||||
// Extract salts from hashes
|
||||
const salts = hashes.map((hash) => {
|
||||
const parts = hash.split("$");
|
||||
return parts[3].substring(0, 22); // Salt is 22 characters
|
||||
});
|
||||
|
||||
// All salts should be unique
|
||||
const uniqueSalts = new Set(salts);
|
||||
expect(uniqueSalts.size).toBe(100);
|
||||
|
||||
// Check for patterns in salts (should be random)
|
||||
for (let i = 1; i < salts.length; i++) {
|
||||
// Salts should not be sequential or predictable
|
||||
expect(salts[i]).not.toBe(salts[i - 1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
443
src/server/security/rate-limit.test.ts
Normal file
443
src/server/security/rate-limit.test.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* Rate Limiting Tests
|
||||
* Tests for rate limiting mechanisms on authentication endpoints
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
||||
import {
|
||||
checkRateLimit,
|
||||
getClientIP,
|
||||
rateLimitLogin,
|
||||
rateLimitPasswordReset,
|
||||
rateLimitRegistration,
|
||||
rateLimitEmailVerification,
|
||||
clearRateLimitStore,
|
||||
RATE_LIMITS
|
||||
} from "~/server/security";
|
||||
import { createMockEvent, randomIP } from "./test-utils";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
describe("Rate Limiting", () => {
|
||||
// Clear rate limit store before each test to ensure isolation
|
||||
beforeEach(() => {
|
||||
clearRateLimitStore();
|
||||
});
|
||||
|
||||
describe("checkRateLimit", () => {
|
||||
it("should allow requests within rate limit", () => {
|
||||
const identifier = `test-${Date.now()}`;
|
||||
const maxAttempts = 5;
|
||||
const windowMs = 60000;
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const remaining = checkRateLimit(identifier, maxAttempts, windowMs);
|
||||
expect(remaining).toBe(maxAttempts - i - 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("should block requests exceeding rate limit", () => {
|
||||
const identifier = `test-${Date.now()}`;
|
||||
const maxAttempts = 3;
|
||||
const windowMs = 60000;
|
||||
|
||||
// Use up all attempts
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
checkRateLimit(identifier, maxAttempts, windowMs);
|
||||
}
|
||||
|
||||
// Next attempt should throw
|
||||
expect(() => {
|
||||
checkRateLimit(identifier, maxAttempts, windowMs);
|
||||
}).toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("should include remaining time in error message", () => {
|
||||
const identifier = `test-${Date.now()}`;
|
||||
const maxAttempts = 2;
|
||||
const windowMs = 60000;
|
||||
|
||||
// Use up all attempts
|
||||
checkRateLimit(identifier, maxAttempts, windowMs);
|
||||
checkRateLimit(identifier, maxAttempts, windowMs);
|
||||
|
||||
try {
|
||||
checkRateLimit(identifier, maxAttempts, windowMs);
|
||||
expect.unreachable("Should have thrown TRPCError");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(TRPCError);
|
||||
const trpcError = error as TRPCError;
|
||||
expect(trpcError.code).toBe("TOO_MANY_REQUESTS");
|
||||
expect(trpcError.message).toMatch(/Try again in \d+ seconds/);
|
||||
}
|
||||
});
|
||||
|
||||
it("should reset after time window expires", async () => {
|
||||
const identifier = `test-${Date.now()}`;
|
||||
const maxAttempts = 3;
|
||||
const windowMs = 100; // 100ms window for fast testing
|
||||
|
||||
// Use up all attempts
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
checkRateLimit(identifier, maxAttempts, windowMs);
|
||||
}
|
||||
|
||||
// Should be blocked
|
||||
expect(() => {
|
||||
checkRateLimit(identifier, maxAttempts, windowMs);
|
||||
}).toThrow(TRPCError);
|
||||
|
||||
// Wait for window to expire
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
// Should be allowed again
|
||||
const remaining = checkRateLimit(identifier, maxAttempts, windowMs);
|
||||
expect(remaining).toBe(maxAttempts - 1);
|
||||
});
|
||||
|
||||
it("should handle concurrent requests correctly", () => {
|
||||
const identifier = `test-${Date.now()}`;
|
||||
const maxAttempts = 10;
|
||||
const windowMs = 60000;
|
||||
|
||||
// Simulate concurrent requests
|
||||
const results: number[] = [];
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
results.push(checkRateLimit(identifier, maxAttempts, windowMs));
|
||||
}
|
||||
|
||||
// All should succeed with decreasing remaining counts
|
||||
expect(results).toEqual([9, 8, 7, 6, 5, 4, 3, 2, 1, 0]);
|
||||
});
|
||||
|
||||
it("should isolate different identifiers", () => {
|
||||
const maxAttempts = 3;
|
||||
const windowMs = 60000;
|
||||
|
||||
const id1 = `test1-${Date.now()}`;
|
||||
const id2 = `test2-${Date.now()}`;
|
||||
|
||||
// Use up attempts for id1
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
checkRateLimit(id1, maxAttempts, windowMs);
|
||||
}
|
||||
|
||||
// id1 should be blocked
|
||||
expect(() => {
|
||||
checkRateLimit(id1, maxAttempts, windowMs);
|
||||
}).toThrow(TRPCError);
|
||||
|
||||
// id2 should still work
|
||||
const remaining = checkRateLimit(id2, maxAttempts, windowMs);
|
||||
expect(remaining).toBe(maxAttempts - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getClientIP", () => {
|
||||
it("should extract IP from x-forwarded-for header", () => {
|
||||
const event = createMockEvent({
|
||||
headers: { "x-forwarded-for": "192.168.1.1, 10.0.0.1" }
|
||||
});
|
||||
|
||||
const ip = getClientIP(event);
|
||||
expect(ip).toBe("192.168.1.1");
|
||||
});
|
||||
|
||||
it("should extract IP from x-real-ip header", () => {
|
||||
const event = createMockEvent({
|
||||
headers: { "x-real-ip": "192.168.1.2" }
|
||||
});
|
||||
|
||||
const ip = getClientIP(event);
|
||||
expect(ip).toBe("192.168.1.2");
|
||||
});
|
||||
|
||||
it("should prefer x-forwarded-for over x-real-ip", () => {
|
||||
const event = createMockEvent({
|
||||
headers: {
|
||||
"x-forwarded-for": "192.168.1.1",
|
||||
"x-real-ip": "192.168.1.2"
|
||||
}
|
||||
});
|
||||
|
||||
const ip = getClientIP(event);
|
||||
expect(ip).toBe("192.168.1.1");
|
||||
});
|
||||
|
||||
it("should return unknown when no IP headers present", () => {
|
||||
const event = createMockEvent({});
|
||||
const ip = getClientIP(event);
|
||||
expect(ip).toBe("unknown");
|
||||
});
|
||||
|
||||
it("should trim whitespace from IP addresses", () => {
|
||||
const event = createMockEvent({
|
||||
headers: { "x-forwarded-for": " 192.168.1.1 , 10.0.0.1" }
|
||||
});
|
||||
|
||||
const ip = getClientIP(event);
|
||||
expect(ip).toBe("192.168.1.1");
|
||||
});
|
||||
|
||||
it("should handle IPv6 addresses", () => {
|
||||
const event = createMockEvent({
|
||||
headers: {
|
||||
"x-forwarded-for": "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
|
||||
}
|
||||
});
|
||||
|
||||
const ip = getClientIP(event);
|
||||
expect(ip).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rateLimitLogin", () => {
|
||||
it("should enforce both IP and email rate limits", () => {
|
||||
const ip = randomIP();
|
||||
|
||||
// Should allow up to LOGIN_IP max attempts (5) with different emails
|
||||
// Use different emails to avoid hitting email rate limit
|
||||
for (let i = 0; i < RATE_LIMITS.LOGIN_IP.maxAttempts; i++) {
|
||||
const email = `test-${Date.now()}-${i}@example.com`;
|
||||
rateLimitLogin(email, ip);
|
||||
}
|
||||
|
||||
// Next attempt should fail due to IP limit
|
||||
expect(() => {
|
||||
const email = `test-${Date.now()}-final@example.com`;
|
||||
rateLimitLogin(email, ip);
|
||||
}).toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("should limit by email independently of IP", () => {
|
||||
const email = `test-${Date.now()}@example.com`;
|
||||
|
||||
// Use different IPs but same email
|
||||
for (let i = 0; i < RATE_LIMITS.LOGIN_EMAIL.maxAttempts; i++) {
|
||||
rateLimitLogin(email, randomIP());
|
||||
}
|
||||
|
||||
// Next attempt with different IP should still fail due to email limit
|
||||
expect(() => {
|
||||
rateLimitLogin(email, randomIP());
|
||||
}).toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("should allow different emails from same IP within IP limit", () => {
|
||||
const ip = randomIP();
|
||||
|
||||
// Use different emails but same IP
|
||||
for (let i = 0; i < RATE_LIMITS.LOGIN_IP.maxAttempts; i++) {
|
||||
const email = `test${i}-${Date.now()}@example.com`;
|
||||
rateLimitLogin(email, ip);
|
||||
}
|
||||
|
||||
// Next attempt should fail due to IP limit
|
||||
expect(() => {
|
||||
rateLimitLogin(`new-${Date.now()}@example.com`, ip);
|
||||
}).toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rateLimitPasswordReset", () => {
|
||||
it("should enforce password reset rate limit", () => {
|
||||
const ip = randomIP();
|
||||
|
||||
// Should allow up to PASSWORD_RESET_IP max attempts (3)
|
||||
for (let i = 0; i < RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts; i++) {
|
||||
rateLimitPasswordReset(ip);
|
||||
}
|
||||
|
||||
// Next attempt should fail
|
||||
expect(() => {
|
||||
rateLimitPasswordReset(ip);
|
||||
}).toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("should isolate password reset limits from login limits", () => {
|
||||
const ip = randomIP();
|
||||
const email = `test-${Date.now()}@example.com`;
|
||||
|
||||
// Use up password reset limit
|
||||
for (let i = 0; i < RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts; i++) {
|
||||
rateLimitPasswordReset(ip);
|
||||
}
|
||||
|
||||
// Should still be able to login (different limit)
|
||||
rateLimitLogin(email, ip);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rateLimitRegistration", () => {
|
||||
it("should enforce registration rate limit", () => {
|
||||
const ip = randomIP();
|
||||
|
||||
// Should allow up to REGISTRATION_IP max attempts (3)
|
||||
for (let i = 0; i < RATE_LIMITS.REGISTRATION_IP.maxAttempts; i++) {
|
||||
rateLimitRegistration(ip);
|
||||
}
|
||||
|
||||
// Next attempt should fail
|
||||
expect(() => {
|
||||
rateLimitRegistration(ip);
|
||||
}).toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rateLimitEmailVerification", () => {
|
||||
it("should enforce email verification rate limit", () => {
|
||||
const ip = randomIP();
|
||||
|
||||
// Should allow up to EMAIL_VERIFICATION_IP max attempts (5)
|
||||
for (let i = 0; i < RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts; i++) {
|
||||
rateLimitEmailVerification(ip);
|
||||
}
|
||||
|
||||
// Next attempt should fail
|
||||
expect(() => {
|
||||
rateLimitEmailVerification(ip);
|
||||
}).toThrow(TRPCError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rate Limit Attack Scenarios", () => {
|
||||
it("should prevent brute force login attacks", () => {
|
||||
const email = "victim@example.com";
|
||||
const attackerIP = "1.2.3.4";
|
||||
|
||||
// Simulate brute force attack
|
||||
let blockedAtAttempt = 0;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
try {
|
||||
rateLimitLogin(email, attackerIP);
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
blockedAtAttempt = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should be blocked before 10 attempts
|
||||
expect(blockedAtAttempt).toBeLessThan(10);
|
||||
expect(blockedAtAttempt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should prevent distributed brute force from multiple IPs", () => {
|
||||
const email = "victim@example.com";
|
||||
|
||||
// Simulate distributed attack from different IPs
|
||||
let blockedAtAttempt = 0;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
try {
|
||||
rateLimitLogin(email, randomIP());
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
blockedAtAttempt = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should be blocked at email limit (3 attempts)
|
||||
expect(blockedAtAttempt).toBeLessThanOrEqual(
|
||||
RATE_LIMITS.LOGIN_EMAIL.maxAttempts
|
||||
);
|
||||
});
|
||||
|
||||
it("should prevent account enumeration via registration spam", () => {
|
||||
const attackerIP = randomIP();
|
||||
|
||||
// Try to register many accounts to enumerate valid emails
|
||||
let blockedAtAttempt = 0;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
try {
|
||||
rateLimitRegistration(attackerIP);
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
blockedAtAttempt = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should be blocked at registration limit (3 attempts)
|
||||
expect(blockedAtAttempt).toBe(RATE_LIMITS.REGISTRATION_IP.maxAttempts);
|
||||
});
|
||||
|
||||
it("should prevent password reset spam attacks", () => {
|
||||
const attackerIP = randomIP();
|
||||
|
||||
// Try to spam password resets
|
||||
let blockedAtAttempt = 0;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
try {
|
||||
rateLimitPasswordReset(attackerIP);
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
blockedAtAttempt = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should be blocked at password reset limit (3 attempts)
|
||||
expect(blockedAtAttempt).toBe(RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rate Limit Configuration", () => {
|
||||
it("should have reasonable limits configured", () => {
|
||||
// Login should be more permissive than registration
|
||||
expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan(
|
||||
RATE_LIMITS.REGISTRATION_IP.maxAttempts
|
||||
);
|
||||
|
||||
// All limits should be positive
|
||||
expect(RATE_LIMITS.LOGIN_IP.maxAttempts).toBeGreaterThan(0);
|
||||
expect(RATE_LIMITS.LOGIN_EMAIL.maxAttempts).toBeGreaterThan(0);
|
||||
expect(RATE_LIMITS.PASSWORD_RESET_IP.maxAttempts).toBeGreaterThan(0);
|
||||
expect(RATE_LIMITS.REGISTRATION_IP.maxAttempts).toBeGreaterThan(0);
|
||||
expect(RATE_LIMITS.EMAIL_VERIFICATION_IP.maxAttempts).toBeGreaterThan(0);
|
||||
|
||||
// All windows should be at least 1 minute
|
||||
expect(RATE_LIMITS.LOGIN_IP.windowMs).toBeGreaterThanOrEqual(60000);
|
||||
expect(RATE_LIMITS.LOGIN_EMAIL.windowMs).toBeGreaterThanOrEqual(60000);
|
||||
expect(RATE_LIMITS.PASSWORD_RESET_IP.windowMs).toBeGreaterThanOrEqual(
|
||||
60000
|
||||
);
|
||||
expect(RATE_LIMITS.REGISTRATION_IP.windowMs).toBeGreaterThanOrEqual(
|
||||
60000
|
||||
);
|
||||
expect(RATE_LIMITS.EMAIL_VERIFICATION_IP.windowMs).toBeGreaterThanOrEqual(
|
||||
60000
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance", () => {
|
||||
it("should handle high volume of rate limit checks efficiently", () => {
|
||||
const start = performance.now();
|
||||
|
||||
// Check 1000 different identifiers
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
checkRateLimit(`test-${i}`, 5, 60000);
|
||||
}
|
||||
|
||||
const duration = performance.now() - start;
|
||||
|
||||
// Should complete in less than 100ms
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("should not leak memory with many identifiers", () => {
|
||||
// Create many rate limit entries
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
checkRateLimit(`test-${i}`, 5, 60000);
|
||||
}
|
||||
|
||||
// This test mainly ensures no crashes occur
|
||||
// Memory cleanup is tested by the cleanup interval in security.ts
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
188
src/server/security/test-utils.ts
Normal file
188
src/server/security/test-utils.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Security Test Utilities
|
||||
* Shared helpers for security-related tests
|
||||
*/
|
||||
|
||||
import type { H3Event } from "vinxi/http";
|
||||
import { SignJWT } from "jose";
|
||||
import { env } from "~/env/server";
|
||||
|
||||
/**
|
||||
* Create a mock H3Event for testing
|
||||
* Creates a minimal structure that works with our cookie/header fallback logic
|
||||
*/
|
||||
export function createMockEvent(options: {
|
||||
headers?: Record<string, string>;
|
||||
cookies?: Record<string, string>;
|
||||
method?: string;
|
||||
url?: string;
|
||||
}): H3Event {
|
||||
const {
|
||||
headers = {},
|
||||
cookies = {},
|
||||
method = "POST",
|
||||
url = "http://localhost:3000/"
|
||||
} = options;
|
||||
|
||||
const cookieString = Object.entries(cookies)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("; ");
|
||||
|
||||
const allHeaders = {
|
||||
...headers,
|
||||
...(cookieString ? { cookie: cookieString } : {})
|
||||
};
|
||||
|
||||
// Try to create Headers object, fall back to plain object if headers contain invalid values
|
||||
let headersObj: Headers | Record<string, string>;
|
||||
try {
|
||||
headersObj = new Headers(allHeaders);
|
||||
} catch (e) {
|
||||
// If Headers constructor fails (e.g., unicode in headers), use plain object
|
||||
headersObj = allHeaders;
|
||||
}
|
||||
|
||||
// Create mock event with headers accessible via .headers.get() and .node.req.headers
|
||||
const mockEvent = {
|
||||
headers: headersObj,
|
||||
node: {
|
||||
req: {
|
||||
headers: allHeaders
|
||||
},
|
||||
res: {
|
||||
cookies: {}
|
||||
}
|
||||
}
|
||||
} as unknown as H3Event;
|
||||
|
||||
return mockEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a valid JWT token for testing
|
||||
*/
|
||||
export async function createTestJWT(
|
||||
userId: string,
|
||||
expiresIn: string = "1h"
|
||||
): Promise<string> {
|
||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||
return await new SignJWT({ id: userId })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime(expiresIn)
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an expired JWT token for testing
|
||||
*/
|
||||
export async function createExpiredJWT(userId: string): Promise<string> {
|
||||
const secret = new TextEncoder().encode(env.JWT_SECRET_KEY);
|
||||
return await new SignJWT({ id: userId })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("-1h") // Expired 1 hour ago
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT with invalid signature
|
||||
*/
|
||||
export async function createInvalidSignatureJWT(
|
||||
userId: string
|
||||
): Promise<string> {
|
||||
const wrongSecret = new TextEncoder().encode("wrong-secret-key");
|
||||
return await new SignJWT({ id: userId })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("1h")
|
||||
.sign(wrongSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate test credentials
|
||||
*/
|
||||
export function createTestCredentials() {
|
||||
return {
|
||||
email: `test-${Date.now()}@example.com`,
|
||||
password: "TestPass123!@#",
|
||||
passwordConfirmation: "TestPass123!@#"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Common SQL injection payloads
|
||||
*/
|
||||
export const SQL_INJECTION_PAYLOADS = [
|
||||
"' OR '1'='1",
|
||||
"'; DROP TABLE User; --",
|
||||
"admin'--",
|
||||
"' UNION SELECT * FROM User--",
|
||||
"1' OR 1=1--",
|
||||
"' OR 'x'='x",
|
||||
"1; DELETE FROM User WHERE 1=1--",
|
||||
"' AND 1=0 UNION ALL SELECT * FROM User--"
|
||||
];
|
||||
|
||||
/**
|
||||
* Common XSS payloads
|
||||
*/
|
||||
export const XSS_PAYLOADS = [
|
||||
"<script>alert('XSS')</script>",
|
||||
"<img src=x onerror=alert('XSS')>",
|
||||
"javascript:alert('XSS')",
|
||||
"<svg onload=alert('XSS')>",
|
||||
"<iframe src='javascript:alert(\"XSS\")'></iframe>",
|
||||
"<body onload=alert('XSS')>",
|
||||
"<input onfocus=alert('XSS') autofocus>"
|
||||
];
|
||||
|
||||
/**
|
||||
* Wait for async operations with timeout
|
||||
*/
|
||||
export async function waitFor(
|
||||
condition: () => boolean | Promise<boolean>,
|
||||
timeout: number = 5000,
|
||||
interval: number = 100
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
if (await condition()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
|
||||
throw new Error(`Timeout waiting for condition after ${timeout}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure execution time
|
||||
*/
|
||||
export async function measureTime<T>(
|
||||
fn: () => Promise<T>
|
||||
): Promise<{ result: T; duration: number }> {
|
||||
const start = Date.now();
|
||||
const result = await fn();
|
||||
const duration = Date.now() - start;
|
||||
return { result, duration };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random string for testing
|
||||
*/
|
||||
export function randomString(length: number = 10): string {
|
||||
const chars =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
return Array.from(
|
||||
{ length },
|
||||
() => chars[Math.floor(Math.random() * chars.length)]
|
||||
).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random IP address
|
||||
*/
|
||||
export function randomIP(): string {
|
||||
return Array.from({ length: 4 }, () => Math.floor(Math.random() * 256)).join(
|
||||
"."
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user