Files
Todo/lib/__tests__/security.test.ts
Paul Bailey 534796edd7 feat: implement security enhancements (task 17)
- Add rate limiting for login, registration, and invitations
- Implement CSRF protection with SameSite cookies and security headers
- Configure secure session cookies with httpOnly and secure flags
- Enhance input sanitization for XSS and injection prevention
- Add comprehensive security tests (41 tests passing)
- Document security enhancements in SECURITY_ENHANCEMENTS.md

Requirements: 9.3, 9.4, 9.5
2025-12-02 01:29:35 +00:00

338 lines
10 KiB
TypeScript

/**
* Security enhancements tests
* Tests for rate limiting, input sanitization, and security headers
*
* Requirements: 9.3, 9.4, 9.5
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
checkRateLimit,
resetRateLimit,
LOGIN_RATE_LIMIT,
REGISTER_RATE_LIMIT,
INVITATION_RATE_LIMIT,
getClientIdentifier
} from '../rate-limit'
import {
sanitizeString,
sanitizeEmail,
sanitizeHtml,
sanitizeSqlInput,
validateEmail,
validatePassword
} from '../validation'
describe('Rate Limiting', () => {
beforeEach(() => {
// Reset rate limits before each test
resetRateLimit('test:key')
resetRateLimit('login:test@example.com')
resetRateLimit('register:192.168.1.1')
})
describe('checkRateLimit', () => {
it('should allow requests within the limit', () => {
const result = checkRateLimit('test:key', {
maxRequests: 5,
windowMs: 60000,
})
expect(result.allowed).toBe(true)
expect(result.remaining).toBe(4)
})
it('should block requests exceeding the limit', () => {
const config = {
maxRequests: 3,
windowMs: 60000,
}
// Make 3 requests (should all succeed)
for (let i = 0; i < 3; i++) {
const result = checkRateLimit('test:key', config)
expect(result.allowed).toBe(true)
}
// 4th request should be blocked
const result = checkRateLimit('test:key', config)
expect(result.allowed).toBe(false)
expect(result.remaining).toBe(0)
expect(result.retryAfter).toBeGreaterThan(0)
})
it('should reset after window expires', () => {
const config = {
maxRequests: 2,
windowMs: 100, // 100ms window
}
// Make 2 requests
checkRateLimit('test:key', config)
checkRateLimit('test:key', config)
// 3rd request should be blocked
let result = checkRateLimit('test:key', config)
expect(result.allowed).toBe(false)
// Wait for window to expire
return new Promise<void>((resolve) => {
setTimeout(() => {
// Should be allowed again
result = checkRateLimit('test:key', config)
expect(result.allowed).toBe(true)
resolve()
}, 150)
})
})
it('should track different keys separately', () => {
const config = {
maxRequests: 2,
windowMs: 60000,
}
// Make 2 requests for key1
checkRateLimit('test:key1', config)
checkRateLimit('test:key1', config)
// key1 should be blocked
let result = checkRateLimit('test:key1', config)
expect(result.allowed).toBe(false)
// key2 should still be allowed
result = checkRateLimit('test:key2', config)
expect(result.allowed).toBe(true)
})
})
describe('resetRateLimit', () => {
it('should reset rate limit for a key', () => {
const config = {
maxRequests: 2,
windowMs: 60000,
}
// Exhaust the limit
checkRateLimit('test:key', config)
checkRateLimit('test:key', config)
// Should be blocked
let result = checkRateLimit('test:key', config)
expect(result.allowed).toBe(false)
// Reset the limit
resetRateLimit('test:key')
// Should be allowed again
result = checkRateLimit('test:key', config)
expect(result.allowed).toBe(true)
})
})
describe('Rate limit configurations', () => {
it('should have appropriate login rate limit', () => {
expect(LOGIN_RATE_LIMIT.maxRequests).toBe(5)
expect(LOGIN_RATE_LIMIT.windowMs).toBe(15 * 60 * 1000) // 15 minutes
})
it('should have appropriate registration rate limit', () => {
expect(REGISTER_RATE_LIMIT.maxRequests).toBe(3)
expect(REGISTER_RATE_LIMIT.windowMs).toBe(60 * 60 * 1000) // 1 hour
})
it('should have appropriate invitation rate limit', () => {
expect(INVITATION_RATE_LIMIT.maxRequests).toBe(10)
expect(INVITATION_RATE_LIMIT.windowMs).toBe(60 * 60 * 1000) // 1 hour
})
})
describe('getClientIdentifier', () => {
it('should extract IP from X-Forwarded-For header', () => {
const headers = new Headers()
headers.set('x-forwarded-for', '192.168.1.1, 10.0.0.1')
const identifier = getClientIdentifier(headers)
expect(identifier).toBe('192.168.1.1')
})
it('should extract IP from X-Real-IP header', () => {
const headers = new Headers()
headers.set('x-real-ip', '192.168.1.1')
const identifier = getClientIdentifier(headers)
expect(identifier).toBe('192.168.1.1')
})
it('should return unknown when no IP headers present', () => {
const headers = new Headers()
const identifier = getClientIdentifier(headers)
expect(identifier).toBe('unknown')
})
})
})
describe('Input Sanitization', () => {
describe('sanitizeString', () => {
it('should trim whitespace', () => {
expect(sanitizeString(' hello ')).toBe('hello')
expect(sanitizeString('\n\thello\t\n')).toBe('hello')
})
it('should remove null bytes', () => {
expect(sanitizeString('hello\0world')).toBe('helloworld')
})
it('should remove control characters', () => {
expect(sanitizeString('hello\x00\x01\x02world')).toBe('helloworld')
})
it('should handle null and undefined', () => {
expect(sanitizeString(null)).toBe('')
expect(sanitizeString(undefined)).toBe('')
})
it('should preserve newlines and tabs', () => {
expect(sanitizeString('hello\nworld')).toBe('hello\nworld')
expect(sanitizeString('hello\tworld')).toBe('hello\tworld')
})
})
describe('sanitizeEmail', () => {
it('should convert to lowercase', () => {
expect(sanitizeEmail('TEST@EXAMPLE.COM')).toBe('test@example.com')
})
it('should trim whitespace', () => {
expect(sanitizeEmail(' test@example.com ')).toBe('test@example.com')
})
it('should remove internal whitespace', () => {
expect(sanitizeEmail('test @example.com')).toBe('test@example.com')
})
it('should remove control characters', () => {
expect(sanitizeEmail('test\x00@example.com')).toBe('test@example.com')
})
it('should handle null and undefined', () => {
expect(sanitizeEmail(null)).toBe('')
expect(sanitizeEmail(undefined)).toBe('')
})
})
describe('sanitizeHtml', () => {
it('should remove HTML tags', () => {
expect(sanitizeHtml('<p>hello</p>')).toBe('hello')
expect(sanitizeHtml('<div><span>test</span></div>')).toBe('test')
})
it('should remove script tags and content', () => {
expect(sanitizeHtml('<script>alert("xss")</script>hello')).toBe('hello')
})
it('should remove event handlers', () => {
expect(sanitizeHtml('<div onclick="alert()">test</div>')).toBe('test')
expect(sanitizeHtml('<img onerror="alert()" />')).toBe('')
})
it('should remove javascript: protocol', () => {
expect(sanitizeHtml('javascript:alert()')).toBe('alert()')
})
it('should encode special HTML characters', () => {
// Test encoding of special characters that aren't part of tags
const result = sanitizeHtml('&"\'/')
expect(result).toContain('&amp;')
expect(result).toContain('&quot;')
expect(result).toContain('&#x27;')
expect(result).toContain('&#x2F;')
// Note: The regex /<[^>]*>/g will match anything between < and >
// This is intentional for security - it's better to be overly cautious
// and remove potential tags than to risk XSS
const result2 = sanitizeHtml('test & value')
expect(result2).toBe('test &amp; value')
})
it('should handle empty input', () => {
expect(sanitizeHtml('')).toBe('')
})
})
describe('sanitizeSqlInput', () => {
it('should remove SQL comment markers', () => {
expect(sanitizeSqlInput('test -- comment')).toBe('test comment')
expect(sanitizeSqlInput('test /* comment */')).toBe('test comment ')
})
it('should remove semicolons', () => {
expect(sanitizeSqlInput('test; DROP TABLE')).toBe('test DROP TABLE')
})
it('should handle empty input', () => {
expect(sanitizeSqlInput('')).toBe('')
})
})
})
describe('Input Validation', () => {
describe('validateEmail', () => {
it('should accept valid emails', () => {
const result = validateEmail('test@example.com')
expect(result.isValid).toBe(true)
})
it('should reject invalid emails', () => {
expect(validateEmail('invalid').isValid).toBe(false)
expect(validateEmail('test@').isValid).toBe(false)
expect(validateEmail('@example.com').isValid).toBe(false)
})
it('should reject empty emails', () => {
expect(validateEmail('').isValid).toBe(false)
})
it('should reject emails that are too long', () => {
const longEmail = 'a'.repeat(256) + '@example.com'
expect(validateEmail(longEmail).isValid).toBe(false)
})
it('should sanitize email before validation', () => {
const result = validateEmail(' TEST@EXAMPLE.COM ')
expect(result.isValid).toBe(true)
})
})
describe('validatePassword', () => {
it('should accept valid passwords', () => {
const result = validatePassword('password123')
expect(result.isValid).toBe(true)
})
it('should reject passwords shorter than 8 characters', () => {
expect(validatePassword('short').isValid).toBe(false)
expect(validatePassword('1234567').isValid).toBe(false)
})
it('should reject empty passwords', () => {
expect(validatePassword('').isValid).toBe(false)
})
it('should reject passwords longer than 128 characters', () => {
const longPassword = 'a'.repeat(129)
expect(validatePassword(longPassword).isValid).toBe(false)
})
it('should accept passwords exactly 8 characters', () => {
expect(validatePassword('12345678').isValid).toBe(true)
})
it('should accept passwords exactly 128 characters', () => {
const maxPassword = 'a'.repeat(128)
expect(validatePassword(maxPassword).isValid).toBe(true)
})
})
})