- 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
338 lines
10 KiB
TypeScript
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('&')
|
|
expect(result).toContain('"')
|
|
expect(result).toContain(''')
|
|
expect(result).toContain('/')
|
|
|
|
// 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 & 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)
|
|
})
|
|
})
|
|
})
|