mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-25 00:47:39 +00:00
Security fixes implemented:
1. CRITICAL: Fix missing await in email verification (pages.ts:139)
- verifyEmail() was called without await, causing race condition
- Response was sent before verification completed
2. CRITICAL: Fix XSS vulnerability in password reset page (pages.ts)
- Added safeJsonForScript() to properly escape tokens in JS context
- JSON.stringify alone doesn't escape </script> sequences
- Now escapes <, >, & as unicode (\u003c, \u003e, \u0026)
3. CRITICAL: Fix XSS in privacy HTML template (server.ts)
- Added escapeHtml() function for all template interpolations
- Prevents XSS if environment variables contain malicious content
4. HIGH: Enable Content Security Policy (server.ts)
- CSP was disabled (contentSecurityPolicy: false)
- Now enabled with strict directives:
- default-src 'self', object-src 'none', frame-ancestors 'none'
5. HIGH: Block wildcard CORS in production (config.ts)
- CORS_ORIGINS=* with credentials is a security vulnerability
- Now throws error in production, warns in development
6. HIGH: Add password reset flow (auth.ts, api.ts, email.ts, schema.prisma)
- Secure token generation with crypto.randomBytes(32)
- 1-hour expiry, one-time use tokens
- Revokes all sessions on password reset
- Prevents email enumeration (same response for all cases)
- Rate limited: 5 requests/15min for forgot-password
- Rate limited: 10 requests/15min for reset-password
Test coverage:
- 45 new tests across 4 test files
- Tests for XSS prevention, CORS blocking, CSP headers
- Tests for password reset flow (API and unit level)
- Fixed pre-existing flaky boundary test in retention-config.spec.ts
96 lines
3.1 KiB
TypeScript
96 lines
3.1 KiB
TypeScript
/**
|
|
* Password Reset Unit Tests
|
|
*
|
|
* Note: The core password reset logic is thoroughly tested via API-level tests
|
|
* in password-reset-api.spec.ts. These tests cover additional edge cases
|
|
* using isolated unit testing of the crypto and validation logic.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import * as crypto from 'crypto';
|
|
import * as bcrypt from 'bcryptjs';
|
|
|
|
describe('Password Reset - Crypto and Validation', () => {
|
|
describe('Reset Token Generation', () => {
|
|
it('should generate cryptographically secure 64-character hex tokens', () => {
|
|
// This tests the same pattern used in auth.ts for token generation
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
|
|
expect(token).toHaveLength(64);
|
|
expect(token).toMatch(/^[0-9a-f]+$/);
|
|
});
|
|
|
|
it('should generate unique tokens each time', () => {
|
|
const tokens = new Set<string>();
|
|
for (let i = 0; i < 100; i++) {
|
|
tokens.add(crypto.randomBytes(32).toString('hex'));
|
|
}
|
|
expect(tokens.size).toBe(100);
|
|
});
|
|
});
|
|
|
|
describe('Password Hashing', () => {
|
|
it('should hash passwords with bcrypt', async () => {
|
|
const password = 'testPassword123';
|
|
const hash = await bcrypt.hash(password, 12);
|
|
|
|
expect(hash).not.toBe(password);
|
|
expect(hash).toMatch(/^\$2[aby]?\$\d{2}\$/);
|
|
});
|
|
|
|
it('should verify correct passwords', async () => {
|
|
const password = 'newSecurePassword123';
|
|
const hash = await bcrypt.hash(password, 12);
|
|
|
|
const isValid = await bcrypt.compare(password, hash);
|
|
expect(isValid).toBe(true);
|
|
});
|
|
|
|
it('should reject incorrect passwords', async () => {
|
|
const password = 'correctPassword';
|
|
const hash = await bcrypt.hash(password, 12);
|
|
|
|
const isValid = await bcrypt.compare('wrongPassword', hash);
|
|
expect(isValid).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Token Expiry Logic', () => {
|
|
const RESET_PASSWORD_TOKEN_EXPIRY_MS = 60 * 60 * 1000; // 1 hour
|
|
|
|
it('should create expiry time 1 hour in the future', () => {
|
|
const now = Date.now();
|
|
const expiryTime = now + RESET_PASSWORD_TOKEN_EXPIRY_MS;
|
|
|
|
const oneHourFromNow = now + 60 * 60 * 1000;
|
|
expect(expiryTime).toBe(oneHourFromNow);
|
|
});
|
|
|
|
it('should correctly identify expired tokens', () => {
|
|
const now = Date.now();
|
|
const expiredAt = BigInt(now - 1000); // Expired 1 second ago
|
|
|
|
const isExpired = Number(expiredAt) < now;
|
|
expect(isExpired).toBe(true);
|
|
});
|
|
|
|
it('should correctly identify valid tokens', () => {
|
|
const now = Date.now();
|
|
const expiresAt = BigInt(now + RESET_PASSWORD_TOKEN_EXPIRY_MS);
|
|
|
|
const isExpired = Number(expiresAt) < now;
|
|
expect(isExpired).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Password Validation', () => {
|
|
it('should accept passwords with 12+ characters', () => {
|
|
const password = '123456789012'; // Exactly 12 chars
|
|
expect(password.length).toBeGreaterThanOrEqual(12);
|
|
});
|
|
|
|
it('should reject passwords under 12 characters', () => {
|
|
const password = '12345678901'; // 11 chars
|
|
expect(password.length).toBeLessThan(12);
|
|
});
|
|
});
|
|
});
|