security hardening

This commit is contained in:
2025-12-28 20:04:29 -05:00
parent aefd467660
commit 1ba20339a8
22 changed files with 5177 additions and 116 deletions

View 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]);
}
});
});
});