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
This commit is contained in:
@@ -254,7 +254,7 @@
|
|||||||
- **Property 35: Migration data preservation**
|
- **Property 35: Migration data preservation**
|
||||||
- **Validates: Requirements 10.3**
|
- **Validates: Requirements 10.3**
|
||||||
|
|
||||||
- [ ] 17. Add security enhancements
|
- [x] 17. Add security enhancements
|
||||||
- Implement rate limiting on login attempts
|
- Implement rate limiting on login attempts
|
||||||
- Add CSRF protection
|
- Add CSRF protection
|
||||||
- Implement secure session cookie configuration
|
- Implement secure session cookie configuration
|
||||||
|
|||||||
257
SECURITY_ENHANCEMENTS.md
Normal file
257
SECURITY_ENHANCEMENTS.md
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
# Security Enhancements
|
||||||
|
|
||||||
|
This document describes the security enhancements implemented for the Ultimate Lists App as part of task 17.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The following security measures have been implemented to protect the application and user data:
|
||||||
|
|
||||||
|
1. **Rate Limiting** (Requirement 9.3)
|
||||||
|
2. **CSRF Protection** (Requirement 9.4)
|
||||||
|
3. **Secure Session Cookie Configuration** (Requirement 9.5)
|
||||||
|
4. **Input Sanitization** (Requirement 9.5)
|
||||||
|
|
||||||
|
## 1. Rate Limiting
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
A comprehensive rate limiting system has been implemented in `lib/rate-limit.ts` to prevent brute force attacks and abuse.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **In-memory rate limiting** with automatic cleanup to prevent memory leaks
|
||||||
|
- **Configurable limits** for different operations
|
||||||
|
- **Client identification** using IP address from headers (X-Forwarded-For, X-Real-IP)
|
||||||
|
- **Graceful degradation** in test environments where headers are not available
|
||||||
|
|
||||||
|
### Rate Limit Configurations
|
||||||
|
|
||||||
|
| Operation | Max Requests | Time Window | Purpose |
|
||||||
|
|-----------|-------------|-------------|---------|
|
||||||
|
| Login | 5 | 15 minutes | Prevent brute force attacks on user accounts |
|
||||||
|
| Registration | 3 | 1 hour | Prevent spam account creation |
|
||||||
|
| Password Reset | 3 | 1 hour | Prevent password reset abuse |
|
||||||
|
| Invitations | 10 | 1 hour | Prevent invitation spam |
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
Rate limiting is automatically applied to:
|
||||||
|
- User registration (`registerUser`)
|
||||||
|
- User login (`loginUser`)
|
||||||
|
- List invitations (`inviteUserToList`)
|
||||||
|
|
||||||
|
On successful login, the rate limit is reset for that user to allow immediate subsequent logins.
|
||||||
|
|
||||||
|
### Error Messages
|
||||||
|
|
||||||
|
When rate limits are exceeded, users receive clear error messages indicating:
|
||||||
|
- The reason for the block
|
||||||
|
- How long they need to wait before trying again
|
||||||
|
|
||||||
|
Example: "Too many login attempts. Please try again in 12 minutes."
|
||||||
|
|
||||||
|
## 2. CSRF Protection
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
CSRF protection is implemented through multiple layers:
|
||||||
|
|
||||||
|
1. **SameSite Cookie Attribute** (`auth.ts`)
|
||||||
|
- Session cookies are configured with `sameSite: 'lax'`
|
||||||
|
- Prevents cookies from being sent in cross-site requests
|
||||||
|
- Protects against CSRF attacks
|
||||||
|
|
||||||
|
2. **Security Headers** (`middleware.ts`)
|
||||||
|
- `X-Content-Type-Options: nosniff` - Prevents MIME type sniffing
|
||||||
|
- `X-Frame-Options: DENY` - Prevents clickjacking attacks
|
||||||
|
- Additional headers for defense in depth
|
||||||
|
|
||||||
|
3. **NextAuth Built-in CSRF Protection**
|
||||||
|
- NextAuth.js v5 includes built-in CSRF token validation
|
||||||
|
- Automatically validates CSRF tokens on authentication requests
|
||||||
|
|
||||||
|
## 3. Secure Session Cookie Configuration
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
Session cookies are configured with security best practices in `auth.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
cookies: {
|
||||||
|
sessionToken: {
|
||||||
|
name: `${process.env.NODE_ENV === 'production' ? '__Secure-' : ''}next-auth.session-token`,
|
||||||
|
options: {
|
||||||
|
httpOnly: true, // Prevent JavaScript access (XSS protection)
|
||||||
|
sameSite: 'lax', // CSRF protection
|
||||||
|
path: '/',
|
||||||
|
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Features
|
||||||
|
|
||||||
|
- **httpOnly**: Prevents client-side JavaScript from accessing the cookie, protecting against XSS attacks
|
||||||
|
- **secure**: Ensures cookies are only sent over HTTPS in production
|
||||||
|
- **sameSite**: Prevents CSRF attacks by restricting cross-site cookie sending
|
||||||
|
- **__Secure- prefix**: In production, uses the `__Secure-` prefix which requires the secure flag
|
||||||
|
|
||||||
|
### Session Configuration
|
||||||
|
|
||||||
|
- **Strategy**: JWT (stateless)
|
||||||
|
- **Max Age**: 30 days
|
||||||
|
- **Update Age**: 24 hours (session refreshed daily)
|
||||||
|
|
||||||
|
## 4. Input Sanitization
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
Comprehensive input sanitization has been implemented in `lib/validation.ts` to prevent various injection attacks.
|
||||||
|
|
||||||
|
### Sanitization Functions
|
||||||
|
|
||||||
|
#### `sanitizeString(input)`
|
||||||
|
- Trims whitespace
|
||||||
|
- Removes null bytes (`\0`)
|
||||||
|
- Removes control characters (except newlines and tabs)
|
||||||
|
- Used for all text inputs
|
||||||
|
|
||||||
|
#### `sanitizeEmail(input)`
|
||||||
|
- Converts to lowercase
|
||||||
|
- Trims whitespace
|
||||||
|
- Removes internal whitespace
|
||||||
|
- Removes control characters
|
||||||
|
- Used for email inputs
|
||||||
|
|
||||||
|
#### `sanitizeHtml(input)`
|
||||||
|
- Removes script tags and their content
|
||||||
|
- Removes event handlers (onclick, onerror, etc.)
|
||||||
|
- Removes javascript: protocol
|
||||||
|
- Removes data: protocol
|
||||||
|
- Strips all HTML tags
|
||||||
|
- Encodes special HTML characters (&, <, >, ", ', /)
|
||||||
|
- Prevents XSS attacks
|
||||||
|
|
||||||
|
#### `sanitizeSqlInput(input)`
|
||||||
|
- Removes SQL comment markers (-- and /* */)
|
||||||
|
- Removes semicolons
|
||||||
|
- Defense-in-depth measure (primary protection is parameterized queries)
|
||||||
|
|
||||||
|
### Application
|
||||||
|
|
||||||
|
Input sanitization is applied to:
|
||||||
|
- All user registration and login inputs
|
||||||
|
- List titles and descriptions
|
||||||
|
- Item titles, descriptions, notes, and tags
|
||||||
|
- Email addresses for invitations
|
||||||
|
- All user profile updates
|
||||||
|
|
||||||
|
## 5. Additional Security Headers
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
Security headers are added in `middleware.ts` to all responses:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
X-XSS-Protection: 1; mode=block
|
||||||
|
X-Frame-Options: DENY
|
||||||
|
Referrer-Policy: strict-origin-when-cross-origin
|
||||||
|
Content-Security-Policy: [comprehensive policy]
|
||||||
|
Permissions-Policy: camera=(), microphone=(), geolocation=()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
|
||||||
|
- **X-Content-Type-Options**: Prevents MIME type sniffing
|
||||||
|
- **X-XSS-Protection**: Enables browser XSS filter
|
||||||
|
- **X-Frame-Options**: Prevents clickjacking
|
||||||
|
- **Referrer-Policy**: Controls referrer information leakage
|
||||||
|
- **Content-Security-Policy**: Restricts resource loading to prevent XSS
|
||||||
|
- **Permissions-Policy**: Restricts access to browser features
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
|
||||||
|
Comprehensive tests have been added in `lib/__tests__/security.test.ts`:
|
||||||
|
|
||||||
|
- **Rate Limiting Tests** (11 tests)
|
||||||
|
- Request allowance within limits
|
||||||
|
- Request blocking when limits exceeded
|
||||||
|
- Rate limit reset after window expiration
|
||||||
|
- Separate tracking for different keys
|
||||||
|
- Rate limit reset functionality
|
||||||
|
- Configuration validation
|
||||||
|
- Client identifier extraction
|
||||||
|
|
||||||
|
- **Input Sanitization Tests** (19 tests)
|
||||||
|
- String sanitization
|
||||||
|
- Email sanitization
|
||||||
|
- HTML sanitization
|
||||||
|
- SQL input sanitization
|
||||||
|
|
||||||
|
- **Input Validation Tests** (11 tests)
|
||||||
|
- Email validation
|
||||||
|
- Password validation
|
||||||
|
|
||||||
|
All 41 security tests pass successfully.
|
||||||
|
|
||||||
|
## Production Considerations
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
The current implementation uses in-memory storage for rate limits. For production deployments with multiple servers, consider:
|
||||||
|
|
||||||
|
- Using Redis or similar distributed cache
|
||||||
|
- Implementing rate limiting at the load balancer level
|
||||||
|
- Monitoring rate limit hits for security analysis
|
||||||
|
|
||||||
|
### Session Security
|
||||||
|
|
||||||
|
- Ensure `NEXTAUTH_SECRET` is set to a strong, random value
|
||||||
|
- Use HTTPS in production (enforced by secure cookie flag)
|
||||||
|
- Monitor session creation and expiration patterns
|
||||||
|
|
||||||
|
### Input Sanitization
|
||||||
|
|
||||||
|
- All user inputs are sanitized before storage
|
||||||
|
- Parameterized queries are used for database operations (Prisma ORM)
|
||||||
|
- Regular security audits recommended
|
||||||
|
|
||||||
|
### Security Headers
|
||||||
|
|
||||||
|
- CSP policy may need adjustment based on third-party integrations
|
||||||
|
- Monitor CSP violations in production
|
||||||
|
- Consider adding Subresource Integrity (SRI) for external scripts
|
||||||
|
|
||||||
|
## Compliance
|
||||||
|
|
||||||
|
These security enhancements help meet common security standards:
|
||||||
|
|
||||||
|
- **OWASP Top 10**: Addresses injection, broken authentication, XSS, and CSRF
|
||||||
|
- **GDPR**: Protects user data through secure authentication and access control
|
||||||
|
- **PCI DSS**: Implements security best practices for web applications
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### Regular Updates
|
||||||
|
|
||||||
|
- Keep dependencies updated (NextAuth, bcrypt, etc.)
|
||||||
|
- Review and update rate limit configurations based on usage patterns
|
||||||
|
- Monitor security advisories for Next.js and related packages
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
Consider implementing:
|
||||||
|
- Logging of rate limit violations
|
||||||
|
- Alerting on suspicious authentication patterns
|
||||||
|
- Regular security audits and penetration testing
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/)
|
||||||
|
- [NextAuth.js Security](https://next-auth.js.org/configuration/options#security)
|
||||||
|
- [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)
|
||||||
|
- [OWASP Rate Limiting](https://cheatsheetseries.owasp.org/cheatsheets/Denial_of_Service_Cheat_Sheet.html)
|
||||||
11
auth.ts
11
auth.ts
@@ -16,6 +16,17 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
maxAge: 30 * 24 * 60 * 60, // 30 days in seconds
|
maxAge: 30 * 24 * 60 * 60, // 30 days in seconds
|
||||||
updateAge: 24 * 60 * 60, // Update session every 24 hours
|
updateAge: 24 * 60 * 60, // Update session every 24 hours
|
||||||
},
|
},
|
||||||
|
cookies: {
|
||||||
|
sessionToken: {
|
||||||
|
name: `${process.env.NODE_ENV === 'production' ? '__Secure-' : ''}next-auth.session-token`,
|
||||||
|
options: {
|
||||||
|
httpOnly: true, // Prevent JavaScript access to cookies (XSS protection)
|
||||||
|
sameSite: 'lax', // CSRF protection - prevents cookie from being sent in cross-site requests
|
||||||
|
path: '/',
|
||||||
|
secure: process.env.NODE_ENV === 'production', // Only send cookie over HTTPS in production
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
providers: [
|
providers: [
|
||||||
Credentials({
|
Credentials({
|
||||||
name: "credentials",
|
name: "credentials",
|
||||||
|
|||||||
337
lib/__tests__/security.test.ts
Normal file
337
lib/__tests__/security.test.ts
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,6 +4,9 @@ import { prisma } from './db'
|
|||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { signIn, signOut } from '@/auth'
|
import { signIn, signOut } from '@/auth'
|
||||||
|
import { checkRateLimit, resetRateLimit, LOGIN_RATE_LIMIT, REGISTER_RATE_LIMIT } from './rate-limit'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
|
import { getClientIdentifier } from './rate-limit'
|
||||||
|
|
||||||
// Validation schemas
|
// Validation schemas
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
@@ -27,7 +30,7 @@ export type LoginResult =
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a new user with email and password
|
* Register a new user with email and password
|
||||||
* Requirements: 1.1, 1.2, 1.3, 1.4, 1.5
|
* Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 9.3 (rate limiting)
|
||||||
*/
|
*/
|
||||||
export async function registerUser(
|
export async function registerUser(
|
||||||
email: string,
|
email: string,
|
||||||
@@ -35,6 +38,26 @@ export async function registerUser(
|
|||||||
name?: string
|
name?: string
|
||||||
): Promise<RegisterResult> {
|
): Promise<RegisterResult> {
|
||||||
try {
|
try {
|
||||||
|
// Rate limiting - Requirement 9.3
|
||||||
|
// Only apply rate limiting when headers are available (not in test environment)
|
||||||
|
try {
|
||||||
|
const headersList = await headers()
|
||||||
|
const clientId = getClientIdentifier(headersList)
|
||||||
|
const rateLimitKey = `register:${clientId}`
|
||||||
|
const rateLimit = checkRateLimit(rateLimitKey, REGISTER_RATE_LIMIT)
|
||||||
|
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
const minutesUntilReset = Math.ceil((rateLimit.retryAfter || 0) / 60000)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Too many registration attempts. Please try again in ${minutesUntilReset} minutes.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (headerError) {
|
||||||
|
// Headers not available (e.g., in test environment) - skip rate limiting
|
||||||
|
console.log('Rate limiting skipped: headers not available')
|
||||||
|
}
|
||||||
|
|
||||||
// Validate input (Requirement 1.5 - password length validation)
|
// Validate input (Requirement 1.5 - password length validation)
|
||||||
const validation = registerSchema.safeParse({ email, password, name })
|
const validation = registerSchema.safeParse({ email, password, name })
|
||||||
|
|
||||||
@@ -93,13 +116,26 @@ export async function registerUser(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Login user with email and password
|
* Login user with email and password
|
||||||
* Requirements: 2.1, 2.2
|
* Requirements: 2.1, 2.2, 9.3 (rate limiting)
|
||||||
*/
|
*/
|
||||||
export async function loginUser(
|
export async function loginUser(
|
||||||
email: string,
|
email: string,
|
||||||
password: string
|
password: string
|
||||||
): Promise<LoginResult> {
|
): Promise<LoginResult> {
|
||||||
try {
|
try {
|
||||||
|
// Rate limiting - Requirement 9.3
|
||||||
|
// Use email as identifier to prevent brute force attacks on specific accounts
|
||||||
|
const rateLimitKey = `login:${email.toLowerCase()}`
|
||||||
|
const rateLimit = checkRateLimit(rateLimitKey, LOGIN_RATE_LIMIT)
|
||||||
|
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
const minutesUntilReset = Math.ceil((rateLimit.retryAfter || 0) / 60000)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Too many login attempts. Please try again in ${minutesUntilReset} minutes.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate input (Requirement 2.1 - credential validation)
|
// Validate input (Requirement 2.1 - credential validation)
|
||||||
const validation = loginSchema.safeParse({ email, password })
|
const validation = loginSchema.safeParse({ email, password })
|
||||||
|
|
||||||
@@ -122,6 +158,9 @@ export async function loginUser(
|
|||||||
})
|
})
|
||||||
|
|
||||||
// If signIn doesn't throw an error, authentication was successful
|
// If signIn doesn't throw an error, authentication was successful
|
||||||
|
// Reset rate limit on successful login
|
||||||
|
resetRateLimit(rateLimitKey)
|
||||||
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle invalid credentials (Requirement 2.2)
|
// Handle invalid credentials (Requirement 2.2)
|
||||||
|
|||||||
189
lib/rate-limit.ts
Normal file
189
lib/rate-limit.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* Rate limiting utilities for security
|
||||||
|
* Implements in-memory rate limiting for login attempts and other sensitive operations
|
||||||
|
*
|
||||||
|
* Requirements: 9.3 - Rate limiting on authentication requests
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface RateLimitEntry {
|
||||||
|
count: number
|
||||||
|
resetAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory store for rate limiting
|
||||||
|
// In production, consider using Redis or similar for distributed systems
|
||||||
|
const rateLimitStore = new Map<string, RateLimitEntry>()
|
||||||
|
|
||||||
|
// Cleanup interval to prevent memory leaks
|
||||||
|
const CLEANUP_INTERVAL = 60 * 60 * 1000 // 1 hour
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
const entries = Array.from(rateLimitStore.entries())
|
||||||
|
for (const [key, entry] of entries) {
|
||||||
|
if (entry.resetAt < now) {
|
||||||
|
rateLimitStore.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, CLEANUP_INTERVAL)
|
||||||
|
|
||||||
|
export interface RateLimitConfig {
|
||||||
|
/**
|
||||||
|
* Maximum number of requests allowed within the window
|
||||||
|
*/
|
||||||
|
maxRequests: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time window in milliseconds
|
||||||
|
*/
|
||||||
|
windowMs: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional identifier (e.g., IP address, user ID)
|
||||||
|
* If not provided, uses a default key
|
||||||
|
*/
|
||||||
|
identifier?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RateLimitResult {
|
||||||
|
/**
|
||||||
|
* Whether the request is allowed
|
||||||
|
*/
|
||||||
|
allowed: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of requests remaining in the current window
|
||||||
|
*/
|
||||||
|
remaining: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamp when the rate limit will reset
|
||||||
|
*/
|
||||||
|
resetAt: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time in milliseconds until the rate limit resets
|
||||||
|
*/
|
||||||
|
retryAfter?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a request is allowed based on rate limiting rules
|
||||||
|
*
|
||||||
|
* @param key - Unique identifier for the rate limit (e.g., "login:user@example.com" or "login:192.168.1.1")
|
||||||
|
* @param config - Rate limit configuration
|
||||||
|
* @returns Rate limit result indicating if request is allowed
|
||||||
|
*/
|
||||||
|
export function checkRateLimit(
|
||||||
|
key: string,
|
||||||
|
config: RateLimitConfig
|
||||||
|
): RateLimitResult {
|
||||||
|
const now = Date.now()
|
||||||
|
const entry = rateLimitStore.get(key)
|
||||||
|
|
||||||
|
// If no entry exists or the window has expired, create a new entry
|
||||||
|
if (!entry || entry.resetAt < now) {
|
||||||
|
const resetAt = now + config.windowMs
|
||||||
|
rateLimitStore.set(key, {
|
||||||
|
count: 1,
|
||||||
|
resetAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: config.maxRequests - 1,
|
||||||
|
resetAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if limit has been exceeded
|
||||||
|
if (entry.count >= config.maxRequests) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
remaining: 0,
|
||||||
|
resetAt: entry.resetAt,
|
||||||
|
retryAfter: entry.resetAt - now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment count and allow request
|
||||||
|
entry.count++
|
||||||
|
rateLimitStore.set(key, entry)
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: config.maxRequests - entry.count,
|
||||||
|
resetAt: entry.resetAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset rate limit for a specific key
|
||||||
|
* Useful for clearing rate limits after successful authentication
|
||||||
|
*
|
||||||
|
* @param key - Unique identifier for the rate limit
|
||||||
|
*/
|
||||||
|
export function resetRateLimit(key: string): void {
|
||||||
|
rateLimitStore.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limit configuration for login attempts
|
||||||
|
* Allows 5 login attempts per 15 minutes per email address
|
||||||
|
*/
|
||||||
|
export const LOGIN_RATE_LIMIT: Omit<RateLimitConfig, 'identifier'> = {
|
||||||
|
maxRequests: 5,
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limit configuration for registration attempts
|
||||||
|
* Allows 3 registration attempts per hour per IP address
|
||||||
|
*/
|
||||||
|
export const REGISTER_RATE_LIMIT: Omit<RateLimitConfig, 'identifier'> = {
|
||||||
|
maxRequests: 3,
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limit configuration for password reset attempts
|
||||||
|
* Allows 3 password reset attempts per hour per email
|
||||||
|
*/
|
||||||
|
export const PASSWORD_RESET_RATE_LIMIT: Omit<RateLimitConfig, 'identifier'> = {
|
||||||
|
maxRequests: 3,
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limit configuration for invitation creation
|
||||||
|
* Allows 10 invitations per hour per user
|
||||||
|
*/
|
||||||
|
export const INVITATION_RATE_LIMIT: Omit<RateLimitConfig, 'identifier'> = {
|
||||||
|
maxRequests: 10,
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client identifier from request headers
|
||||||
|
* Uses X-Forwarded-For header if available, otherwise falls back to a default
|
||||||
|
*
|
||||||
|
* @param headers - Request headers
|
||||||
|
* @returns Client identifier (IP address or default)
|
||||||
|
*/
|
||||||
|
export function getClientIdentifier(headers: Headers): string {
|
||||||
|
// Try to get IP from X-Forwarded-For header (for proxied requests)
|
||||||
|
const forwardedFor = headers.get('x-forwarded-for')
|
||||||
|
if (forwardedFor) {
|
||||||
|
// X-Forwarded-For can contain multiple IPs, use the first one
|
||||||
|
return forwardedFor.split(',')[0].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get IP from X-Real-IP header
|
||||||
|
const realIp = headers.get('x-real-ip')
|
||||||
|
if (realIp) {
|
||||||
|
return realIp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to a default identifier
|
||||||
|
// In production, you might want to handle this differently
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { prisma } from './db'
|
|||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { checkRateLimit, INVITATION_RATE_LIMIT } from './rate-limit'
|
||||||
|
|
||||||
// Validation schemas
|
// Validation schemas
|
||||||
const inviteSchema = z.object({
|
const inviteSchema = z.object({
|
||||||
@@ -63,6 +64,15 @@ export async function inviteUserToList(
|
|||||||
|
|
||||||
const userId = session.user.id
|
const userId = session.user.id
|
||||||
|
|
||||||
|
// Rate limiting - Requirement 9.3
|
||||||
|
const rateLimitKey = `invite:${userId}`
|
||||||
|
const rateLimit = checkRateLimit(rateLimitKey, INVITATION_RATE_LIMIT)
|
||||||
|
|
||||||
|
if (!rateLimit.allowed) {
|
||||||
|
const minutesUntilReset = Math.ceil((rateLimit.retryAfter || 0) / 60000)
|
||||||
|
throw new Error(`Too many invitation attempts. Please try again in ${minutesUntilReset} minutes.`)
|
||||||
|
}
|
||||||
|
|
||||||
// Validate input
|
// Validate input
|
||||||
if (!listId) {
|
if (!listId) {
|
||||||
throw new Error('List ID is required')
|
throw new Error('List ID is required')
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Input validation utilities for the Ultimate Lists App
|
* Input validation utilities for the Ultimate Lists App
|
||||||
* Provides validation functions and sanitization for user inputs
|
* Provides validation functions and sanitization for user inputs
|
||||||
|
*
|
||||||
|
* Requirements: 9.5 - Input sanitization for all user inputs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ValidationResult {
|
export interface ValidationResult {
|
||||||
@@ -9,13 +11,45 @@ export interface ValidationResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize string input by trimming whitespace
|
* Sanitize string input by trimming whitespace and removing potentially dangerous characters
|
||||||
|
* Requirement 9.5 - Input sanitization
|
||||||
*/
|
*/
|
||||||
export function sanitizeString(input: string | null | undefined): string {
|
export function sanitizeString(input: string | null | undefined): string {
|
||||||
if (input === null || input === undefined) {
|
if (input === null || input === undefined) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
return input.trim()
|
|
||||||
|
// Trim whitespace
|
||||||
|
let sanitized = input.trim()
|
||||||
|
|
||||||
|
// Remove null bytes that could cause issues
|
||||||
|
sanitized = sanitized.replace(/\0/g, '')
|
||||||
|
|
||||||
|
// Remove control characters except newlines and tabs
|
||||||
|
sanitized = sanitized.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize email input
|
||||||
|
* Requirement 9.5 - Input sanitization
|
||||||
|
*/
|
||||||
|
export function sanitizeEmail(email: string | null | undefined): string {
|
||||||
|
if (email === null || email === undefined) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to lowercase and trim
|
||||||
|
let sanitized = email.toLowerCase().trim()
|
||||||
|
|
||||||
|
// Remove any whitespace within the email
|
||||||
|
sanitized = sanitized.replace(/\s/g, '')
|
||||||
|
|
||||||
|
// Remove null bytes and control characters
|
||||||
|
sanitized = sanitized.replace(/[\x00-\x1F\x7F]/g, '')
|
||||||
|
|
||||||
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,10 +216,59 @@ export function validateNotes(notes: string | null | undefined): ValidationResul
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize HTML to prevent XSS attacks
|
* Sanitize HTML to prevent XSS attacks
|
||||||
* Basic implementation - strips HTML tags
|
* Strips HTML tags and dangerous characters
|
||||||
|
* Requirement 9.5 - Input sanitization to prevent XSS
|
||||||
*/
|
*/
|
||||||
export function sanitizeHtml(input: string): string {
|
export function sanitizeHtml(input: string): string {
|
||||||
return input.replace(/<[^>]*>/g, '')
|
if (!input) return ''
|
||||||
|
|
||||||
|
let sanitized = input
|
||||||
|
|
||||||
|
// First, remove script tags and their content (before removing other tags)
|
||||||
|
sanitized = sanitized.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||||
|
|
||||||
|
// Remove event handlers (onclick, onerror, etc.)
|
||||||
|
sanitized = sanitized.replace(/on\w+\s*=\s*["'][^"']*["']/gi, '')
|
||||||
|
sanitized = sanitized.replace(/on\w+\s*=\s*[^\s>]*/gi, '')
|
||||||
|
|
||||||
|
// Remove javascript: protocol
|
||||||
|
sanitized = sanitized.replace(/javascript:/gi, '')
|
||||||
|
|
||||||
|
// Remove data: protocol (can be used for XSS)
|
||||||
|
sanitized = sanitized.replace(/data:text\/html/gi, '')
|
||||||
|
|
||||||
|
// Remove all HTML tags
|
||||||
|
sanitized = sanitized.replace(/<[^>]*>/g, '')
|
||||||
|
|
||||||
|
// Encode special HTML characters (after removing tags)
|
||||||
|
sanitized = sanitized
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
.replace(/\//g, '/')
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize SQL input to prevent SQL injection
|
||||||
|
* Note: This is a defense-in-depth measure. Primary protection comes from parameterized queries.
|
||||||
|
* Requirement 9.5 - Input sanitization
|
||||||
|
*/
|
||||||
|
export function sanitizeSqlInput(input: string): string {
|
||||||
|
if (!input) return ''
|
||||||
|
|
||||||
|
// Remove SQL comment markers
|
||||||
|
let sanitized = input.replace(/--/g, '')
|
||||||
|
sanitized = sanitized.replace(/\/\*/g, '')
|
||||||
|
sanitized = sanitized.replace(/\*\//g, '')
|
||||||
|
|
||||||
|
// Remove semicolons that could terminate statements
|
||||||
|
sanitized = sanitized.replace(/;/g, '')
|
||||||
|
|
||||||
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -304,9 +387,10 @@ export function validateItemData(data: {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate email format
|
* Validate email format
|
||||||
|
* Requirement 9.5 - Input validation and sanitization
|
||||||
*/
|
*/
|
||||||
export function validateEmail(email: string): ValidationResult {
|
export function validateEmail(email: string): ValidationResult {
|
||||||
const sanitized = sanitizeString(email)
|
const sanitized = sanitizeEmail(email)
|
||||||
|
|
||||||
if (!sanitized) {
|
if (!sanitized) {
|
||||||
return { isValid: false, error: 'Email is required' }
|
return { isValid: false, error: 'Email is required' }
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ import { NextResponse } from "next/server"
|
|||||||
import type { NextRequest } from "next/server"
|
import type { NextRequest } from "next/server"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware for session validation and route protection
|
* Middleware for session validation, route protection, and security headers
|
||||||
*
|
*
|
||||||
* This middleware:
|
* This middleware:
|
||||||
* - Validates session tokens on protected routes
|
* - Validates session tokens on protected routes
|
||||||
* - Redirects unauthenticated users to login
|
* - Redirects unauthenticated users to login
|
||||||
* - Handles session expiration
|
* - Handles session expiration
|
||||||
* - Allows access to auth pages for unauthenticated users
|
* - Allows access to auth pages for unauthenticated users
|
||||||
|
* - Adds security headers (CSRF protection, XSS protection, etc.)
|
||||||
|
*
|
||||||
|
* Requirements: 3.3, 3.5, 9.4 (CSRF protection), 9.5 (security headers)
|
||||||
*/
|
*/
|
||||||
export default auth((req) => {
|
export default auth((req) => {
|
||||||
const { nextUrl } = req
|
const { nextUrl } = req
|
||||||
@@ -25,25 +28,63 @@ export default auth((req) => {
|
|||||||
// Define auth pages (login, register, etc.)
|
// Define auth pages (login, register, etc.)
|
||||||
const isAuthPage = nextUrl.pathname.startsWith("/auth")
|
const isAuthPage = nextUrl.pathname.startsWith("/auth")
|
||||||
|
|
||||||
|
// Create response
|
||||||
|
let response: NextResponse
|
||||||
|
|
||||||
// Allow access to auth pages regardless of authentication status
|
// Allow access to auth pages regardless of authentication status
|
||||||
if (isAuthPage) {
|
if (isAuthPage) {
|
||||||
// If already logged in and trying to access login/register, redirect to home
|
// If already logged in and trying to access login/register, redirect to home
|
||||||
if (isLoggedIn && (nextUrl.pathname === "/auth/login" || nextUrl.pathname === "/auth/register")) {
|
if (isLoggedIn && (nextUrl.pathname === "/auth/login" || nextUrl.pathname === "/auth/register")) {
|
||||||
return NextResponse.redirect(new URL("/", req.url))
|
response = NextResponse.redirect(new URL("/", req.url))
|
||||||
|
} else {
|
||||||
|
response = NextResponse.next()
|
||||||
}
|
}
|
||||||
return NextResponse.next()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to login if accessing protected route without authentication
|
// Redirect to login if accessing protected route without authentication
|
||||||
if (isProtectedRoute && !isLoggedIn) {
|
else if (isProtectedRoute && !isLoggedIn) {
|
||||||
const loginUrl = new URL("/auth/login", req.url)
|
const loginUrl = new URL("/auth/login", req.url)
|
||||||
// Add callback URL to redirect back after login
|
// Add callback URL to redirect back after login
|
||||||
loginUrl.searchParams.set("callbackUrl", nextUrl.pathname)
|
loginUrl.searchParams.set("callbackUrl", nextUrl.pathname)
|
||||||
return NextResponse.redirect(loginUrl)
|
response = NextResponse.redirect(loginUrl)
|
||||||
|
}
|
||||||
|
// Allow access to public routes
|
||||||
|
else {
|
||||||
|
response = NextResponse.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow access to public routes
|
// Add security headers - Requirement 9.4, 9.5
|
||||||
return NextResponse.next()
|
const headers = response.headers
|
||||||
|
|
||||||
|
// CSRF Protection - SameSite cookie attribute is set in auth.ts
|
||||||
|
// This header provides additional CSRF protection
|
||||||
|
headers.set('X-Content-Type-Options', 'nosniff')
|
||||||
|
|
||||||
|
// XSS Protection
|
||||||
|
headers.set('X-XSS-Protection', '1; mode=block')
|
||||||
|
|
||||||
|
// Prevent clickjacking attacks
|
||||||
|
headers.set('X-Frame-Options', 'DENY')
|
||||||
|
|
||||||
|
// Referrer Policy - don't leak referrer information
|
||||||
|
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||||
|
|
||||||
|
// Content Security Policy - helps prevent XSS attacks
|
||||||
|
// Note: This is a basic CSP. You may need to adjust based on your needs
|
||||||
|
const csp = [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // unsafe-inline and unsafe-eval needed for Next.js
|
||||||
|
"style-src 'self' 'unsafe-inline'", // unsafe-inline needed for Tailwind
|
||||||
|
"img-src 'self' data: blob:",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
].join('; ')
|
||||||
|
headers.set('Content-Security-Policy', csp)
|
||||||
|
|
||||||
|
// Permissions Policy - restrict access to browser features
|
||||||
|
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
|
||||||
|
|
||||||
|
return response
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user