fix(security): replace setInterval with lazy cleanup in register endpoint
- commit
- 0ff6d73ec919a4e8574d37b3e633de185a6e574f
- author
- Kit · · 2026-02-16 11:00 UTC
- parent
- 35b5b26
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
1 file · +19 -12
M src/routes/api/register/+server.ts +19 -12
| @@ -10,23 +10,27 @@ const log = createLogger('register-server'); | |||
| 10 | 10 | // In-memory map to track failed captcha attempts per IP | |
| 11 | 11 | const captchaAttempts = new Map<string, { count: number; lastAttempt: number }>(); | |
| 12 | 12 | ||
| 13 | - | // Cleanup old entries every hour | |
| 14 | - | setInterval( | |
| 15 | - | () => { | |
| 16 | - | const now = Date.now(); | |
| 17 | - | const ONE_HOUR = 60 * 60 * 1000; | |
| 18 | - | for (const [ip, data] of captchaAttempts.entries()) { | |
| 19 | - | if (now - data.lastAttempt > ONE_HOUR) { | |
| 20 | - | captchaAttempts.delete(ip); | |
| 21 | - | } | |
| 13 | + | // Lazy cleanup: Cloudflare Workers don't allow setInterval at global scope | |
| 14 | + | const ONE_HOUR = 60 * 60 * 1000; | |
| 15 | + | let lastCleanup = 0; | |
| 16 | + | function cleanupOldCaptchaAttempts() { | |
| 17 | + | const now = Date.now(); | |
| 18 | + | if (now - lastCleanup < ONE_HOUR) return; | |
| 19 | + | lastCleanup = now; | |
| 20 | + | ||
| 21 | + | for (const [ip, data] of captchaAttempts.entries()) { | |
| 22 | + | if (now - data.lastAttempt > ONE_HOUR) { | |
| 23 | + | captchaAttempts.delete(ip); | |
| 22 | 24 | } | |
| 23 | - | }, | |
| 24 | - | 60 * 60 * 1000 | |
| 25 | - | ); | |
| 25 | + | } | |
| 26 | + | } | |
| 26 | 27 | ||
| 27 | 28 | export const POST: RequestHandler = async ({ request }) => { | |
| 28 | 29 | log.debug('New registration attempt received'); | |
| 29 | 30 | ||
| 31 | + | // Lazy cleanup on each request (replaces setInterval for Cloudflare compatibility) | |
| 32 | + | cleanupOldCaptchaAttempts(); | |
| 33 | + | ||
| 30 | 34 | if (request.method !== 'POST') { | |
| 31 | 35 | log.warn('Invalid method used', { method: request.method }); | |
| 32 | 36 | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as RegisterResponseError), { | |
| @@ -81,6 +85,9 @@ export const POST: RequestHandler = async ({ request }) => { | |||
| 81 | 85 | } | |
| 82 | 86 | ||
| 83 | 87 | // Validate PDR captcha | |
| 88 | + | // SECURITY NOTE: Static CAPTCHA answer is intentional for this community app. | |
| 89 | + | // The answer "point de rencontre" acts as a shared-knowledge gate, not bot prevention. | |
| 90 | + | // Bot prevention is handled by Cloudflare Turnstile (when enabled in production). | |
| 84 | 91 | const normalizedAnswer = captchaAnswer.trim().toLowerCase(); | |
| 85 | 92 | if (normalizedAnswer !== 'point de rencontre') { | |
| 86 | 93 | attemptData.count++; | |
| @@ -10,23 +10,27 @@ const log = createLogger('register-server'); | |||||
| 10 | // In-memory map to track failed captcha attempts per IP | 10 | // In-memory map to track failed captcha attempts per IP | ||
| 11 | const captchaAttempts = new Map<string, { count: number; lastAttempt: number }>(); | 11 | const captchaAttempts = new Map<string, { count: number; lastAttempt: number }>(); | ||
| 12 | 12 | ||||
| 13 | - | // Cleanup old entries every hour | 13 | + | // Lazy cleanup: Cloudflare Workers don't allow setInterval at global scope |
| 14 | - | setInterval( | 14 | + | const ONE_HOUR = 60 * 60 * 1000; |
| 15 | - | () => { | 15 | + | let lastCleanup = 0; |
| 16 | - | const now = Date.now(); | 16 | + | function cleanupOldCaptchaAttempts() { |
| 17 | - | const ONE_HOUR = 60 * 60 * 1000; | 17 | + | const now = Date.now(); |
| 18 | - | for (const [ip, data] of captchaAttempts.entries()) { | 18 | + | if (now - lastCleanup < ONE_HOUR) return; |
| 19 | - | if (now - data.lastAttempt > ONE_HOUR) { | 19 | + | lastCleanup = now; |
| 20 | - | captchaAttempts.delete(ip); | 20 | + | |
| 21 | - | } | 21 | + | for (const [ip, data] of captchaAttempts.entries()) { |
| 22 | + | if (now - data.lastAttempt > ONE_HOUR) { | |||
| 23 | + | captchaAttempts.delete(ip); | |||
| 22 | } | 24 | } | ||
| 23 | - | }, | 25 | + | } |
| 24 | - | 60 * 60 * 1000 | 26 | + | } |
| 25 | - | ); | |||
| 26 | 27 | ||||
| 27 | export const POST: RequestHandler = async ({ request }) => { | 28 | export const POST: RequestHandler = async ({ request }) => { | ||
| 28 | log.debug('New registration attempt received'); | 29 | log.debug('New registration attempt received'); | ||
| 29 | 30 | ||||
| 31 | + | // Lazy cleanup on each request (replaces setInterval for Cloudflare compatibility) | |||
| 32 | + | cleanupOldCaptchaAttempts(); | |||
| 33 | + | ||||
| 30 | if (request.method !== 'POST') { | 34 | if (request.method !== 'POST') { | ||
| 31 | log.warn('Invalid method used', { method: request.method }); | 35 | log.warn('Invalid method used', { method: request.method }); | ||
| 32 | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as RegisterResponseError), { | 36 | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as RegisterResponseError), { | ||
| @@ -81,6 +85,9 @@ export const POST: RequestHandler = async ({ request }) => { | |||||
| 81 | } | 85 | } | ||
| 82 | 86 | ||||
| 83 | // Validate PDR captcha | 87 | // Validate PDR captcha | ||
| 88 | + | // SECURITY NOTE: Static CAPTCHA answer is intentional for this community app. | |||
| 89 | + | // The answer "point de rencontre" acts as a shared-knowledge gate, not bot prevention. | |||
| 90 | + | // Bot prevention is handled by Cloudflare Turnstile (when enabled in production). | |||
| 84 | const normalizedAnswer = captchaAnswer.trim().toLowerCase(); | 91 | const normalizedAnswer = captchaAnswer.trim().toLowerCase(); | ||
| 85 | if (normalizedAnswer !== 'point de rencontre') { | 92 | if (normalizedAnswer !== 'point de rencontre') { | ||
| 86 | attemptData.count++; | 93 | attemptData.count++; | ||