feat(security): Add clearance cookie for login and registration flows
- Implement clearance cookie mechanism for bypassing Turnstile token validation - Update login and registration API routes to set and validate clearance cookies - Enhance security flow by allowing pre-validated sessions to skip token challenge
- commit
- f62d84ad48b6559246d8515b2e2b72f098dcd09d
- author
- Kit · · 2025-02-18 07:23 UTC
- parent
- 3a9c4bf
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
2 files · +47 -18
M src/routes/api/register/+server.ts +11 -1
| @@ -157,5 +157,15 @@ export const POST: RequestHandler = async ({ request }) => { | |||
| 157 | 157 | return new Response(JSON.stringify({ error: 'User registration failed. Possibly user already exists.' } as RegisterResponseError), { status: 409 }); | |
| 158 | 158 | } | |
| 159 | 159 | ||
| 160 | - | return new Response(JSON.stringify({ success: true } as RegisterResponseSuccess), { status: 201 }); | |
| 160 | + | // Set a clearance cookie (valid for 1 hour) to hold the turnstile success. | |
| 161 | + | const headers = new Headers(); | |
| 162 | + | headers.set( | |
| 163 | + | 'set-cookie', | |
| 164 | + | `cf-clearance=1; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Strict` | |
| 165 | + | ); | |
| 166 | + | ||
| 167 | + | return new Response( | |
| 168 | + | JSON.stringify({ success: true } as RegisterResponseSuccess), | |
| 169 | + | { status: 201, headers } | |
| 170 | + | ); | |
| 161 | 171 | }; | |
| @@ -157,5 +157,15 @@ export const POST: RequestHandler = async ({ request }) => { | |||||
| 157 | return new Response(JSON.stringify({ error: 'User registration failed. Possibly user already exists.' } as RegisterResponseError), { status: 409 }); | 157 | return new Response(JSON.stringify({ error: 'User registration failed. Possibly user already exists.' } as RegisterResponseError), { status: 409 }); | ||
| 158 | } | 158 | } | ||
| 159 | 159 | ||||
| 160 | - | return new Response(JSON.stringify({ success: true } as RegisterResponseSuccess), { status: 201 }); | 160 | + | // Set a clearance cookie (valid for 1 hour) to hold the turnstile success. |
| 161 | + | const headers = new Headers(); | |||
| 162 | + | headers.set( | |||
| 163 | + | 'set-cookie', | |||
| 164 | + | `cf-clearance=1; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Strict` | |||
| 165 | + | ); | |||
| 166 | + | ||||
| 167 | + | return new Response( | |||
| 168 | + | JSON.stringify({ success: true } as RegisterResponseSuccess), | |||
| 169 | + | { status: 201, headers } | |||
| 170 | + | ); | |||
| 161 | }; | 171 | }; | ||
M src/routes/api/session/login/+server.ts +36 -17
| @@ -41,18 +41,20 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||
| 41 | 41 | ||
| 42 | 42 | if (request.method !== 'POST') { | |
| 43 | 43 | log.warn('Invalid method used', { method: request.method }); | |
| 44 | - | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), { status: 405 }); | |
| 44 | + | return new Response( | |
| 45 | + | JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), | |
| 46 | + | { status: 405 } | |
| 47 | + | ); | |
| 45 | 48 | } | |
| 46 | 49 | ||
| 47 | 50 | // Get the IP address for rate limiting and Turnstile validation | |
| 48 | 51 | const ip = request.headers.get('x-forwarded-for') || 'unknown'; | |
| 49 | - | const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.'); | |
| 52 | + | const maskedIp = ip.split('.').map((octet, idx) => (idx < 3 ? 'xxx' : octet)).join('.'); | |
| 50 | 53 | const now = Date.now(); | |
| 51 | 54 | const attemptData = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 }; | |
| 52 | 55 | ||
| 53 | - | // If the user has failed three or more times, apply exponential backoff | |
| 54 | 56 | if (attemptData.count >= MAX_ATTEMPTS) { | |
| 55 | - | const delay = Math.pow(2, attemptData.count - MAX_ATTEMPTS + 1) * 1000; // delay in milliseconds | |
| 57 | + | const delay = Math.pow(2, attemptData.count - MAX_ATTEMPTS + 1) * 1000; | |
| 56 | 58 | log.warn('Rate limit exceeded', { maskedIp, delay }); | |
| 57 | 59 | await sleep(delay); | |
| 58 | 60 | } | |
| @@ -62,37 +64,54 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||
| 62 | 64 | body = await request.json(); | |
| 63 | 65 | } catch { | |
| 64 | 66 | log.warn('Invalid JSON payload received'); | |
| 65 | - | return new Response(JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), { status: 400 }); | |
| 67 | + | return new Response( | |
| 68 | + | JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), | |
| 69 | + | { status: 400 } | |
| 70 | + | ); | |
| 66 | 71 | } | |
| 67 | 72 | ||
| 68 | - | const { username, password, turnstileToken } = body as { username: string, password: string, turnstileToken: string }; | |
| 73 | + | // Extract cookie header and clearance cookie BEFORE validating input. | |
| 74 | + | const cookieHeader = request.headers.get('cookie'); | |
| 75 | + | const clearanceCookie = getClearanceCookie(cookieHeader); | |
| 69 | 76 | ||
| 70 | - | if (typeof username !== 'string' || typeof password !== 'string' || typeof turnstileToken !== 'string') { | |
| 71 | - | log.warn('Missing or invalid input fields', { | |
| 77 | + | // Note: Mark turnstileToken as optional if a valid clearance cookie exists. | |
| 78 | + | const { username, password, turnstileToken } = body as { | |
| 79 | + | username: string; | |
| 80 | + | password: string; | |
| 81 | + | turnstileToken?: string; | |
| 82 | + | }; | |
| 83 | + | ||
| 84 | + | if ( | |
| 85 | + | typeof username !== 'string' || | |
| 86 | + | typeof password !== 'string' || | |
| 87 | + | (!clearanceCookie && typeof turnstileToken !== 'string') | |
| 88 | + | ) { | |
| 89 | + | log.warn('Missing or invalid input fields', { | |
| 72 | 90 | hasUsername: typeof username === 'string', | |
| 73 | 91 | hasPassword: typeof password === 'string', | |
| 74 | 92 | hasTurnstileToken: typeof turnstileToken === 'string' | |
| 75 | 93 | }); | |
| 76 | - | return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), { status: 400 }); | |
| 94 | + | return new Response( | |
| 95 | + | JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), | |
| 96 | + | { status: 400 } | |
| 97 | + | ); | |
| 77 | 98 | } | |
| 78 | 99 | ||
| 79 | - | // NEW: Check if a clearance cookie exists | |
| 80 | - | const cookieHeader = request.headers.get('cookie'); | |
| 81 | - | const clearanceCookie = getClearanceCookie(cookieHeader); | |
| 100 | + | // Check for clearance cookie before validating Turnstile token. | |
| 82 | 101 | let isValidTurnstile = false; | |
| 83 | - | ||
| 84 | 102 | if (clearanceCookie) { | |
| 85 | - | // Optional: Validate the clearance cookie if needed. | |
| 86 | 103 | log.debug('Clearance cookie detected; bypassing token challenge.'); | |
| 87 | 104 | isValidTurnstile = true; | |
| 88 | 105 | } else { | |
| 89 | - | // Fallback: Validate the turnstile token | |
| 90 | - | isValidTurnstile = await validateTurnstileToken(turnstileToken, ip); | |
| 106 | + | isValidTurnstile = await validateTurnstileToken(turnstileToken as string, ip); | |
| 91 | 107 | } | |
| 92 | 108 | ||
| 93 | 109 | if (!isValidTurnstile) { | |
| 94 | 110 | log.warn('Invalid Turnstile token', { maskedIp }); | |
| 95 | - | return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError), { status: 400 }); | |
| 111 | + | return new Response( | |
| 112 | + | JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError), | |
| 113 | + | { status: 400 } | |
| 114 | + | ); | |
| 96 | 115 | } | |
| 97 | 116 | ||
| 98 | 117 | // Find user by username | |
| @@ -41,18 +41,20 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||||
| 41 | 41 | ||||
| 42 | if (request.method !== 'POST') { | 42 | if (request.method !== 'POST') { | ||
| 43 | log.warn('Invalid method used', { method: request.method }); | 43 | log.warn('Invalid method used', { method: request.method }); | ||
| 44 | - | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), { status: 405 }); | 44 | + | return new Response( |
| 45 | + | JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), | |||
| 46 | + | { status: 405 } | |||
| 47 | + | ); | |||
| 45 | } | 48 | } | ||
| 46 | 49 | ||||
| 47 | // Get the IP address for rate limiting and Turnstile validation | 50 | // Get the IP address for rate limiting and Turnstile validation | ||
| 48 | const ip = request.headers.get('x-forwarded-for') || 'unknown'; | 51 | const ip = request.headers.get('x-forwarded-for') || 'unknown'; | ||
| 49 | - | const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.'); | 52 | + | const maskedIp = ip.split('.').map((octet, idx) => (idx < 3 ? 'xxx' : octet)).join('.'); |
| 50 | const now = Date.now(); | 53 | const now = Date.now(); | ||
| 51 | const attemptData = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 }; | 54 | const attemptData = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 }; | ||
| 52 | 55 | ||||
| 53 | - | // If the user has failed three or more times, apply exponential backoff | |||
| 54 | if (attemptData.count >= MAX_ATTEMPTS) { | 56 | if (attemptData.count >= MAX_ATTEMPTS) { | ||
| 55 | - | const delay = Math.pow(2, attemptData.count - MAX_ATTEMPTS + 1) * 1000; // delay in milliseconds | 57 | + | const delay = Math.pow(2, attemptData.count - MAX_ATTEMPTS + 1) * 1000; |
| 56 | log.warn('Rate limit exceeded', { maskedIp, delay }); | 58 | log.warn('Rate limit exceeded', { maskedIp, delay }); | ||
| 57 | await sleep(delay); | 59 | await sleep(delay); | ||
| 58 | } | 60 | } | ||
| @@ -62,37 +64,54 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||||
| 62 | body = await request.json(); | 64 | body = await request.json(); | ||
| 63 | } catch { | 65 | } catch { | ||
| 64 | log.warn('Invalid JSON payload received'); | 66 | log.warn('Invalid JSON payload received'); | ||
| 65 | - | return new Response(JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), { status: 400 }); | 67 | + | return new Response( |
| 68 | + | JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), | |||
| 69 | + | { status: 400 } | |||
| 70 | + | ); | |||
| 66 | } | 71 | } | ||
| 67 | 72 | ||||
| 68 | - | const { username, password, turnstileToken } = body as { username: string, password: string, turnstileToken: string }; | 73 | + | // Extract cookie header and clearance cookie BEFORE validating input. |
| 74 | + | const cookieHeader = request.headers.get('cookie'); | |||
| 75 | + | const clearanceCookie = getClearanceCookie(cookieHeader); | |||
| 69 | 76 | ||||
| 70 | - | if (typeof username !== 'string' || typeof password !== 'string' || typeof turnstileToken !== 'string') { | 77 | + | // Note: Mark turnstileToken as optional if a valid clearance cookie exists. |
| 71 | - | log.warn('Missing or invalid input fields', { | 78 | + | const { username, password, turnstileToken } = body as { |
| 79 | + | username: string; | |||
| 80 | + | password: string; | |||
| 81 | + | turnstileToken?: string; | |||
| 82 | + | }; | |||
| 83 | + | ||||
| 84 | + | if ( | |||
| 85 | + | typeof username !== 'string' || | |||
| 86 | + | typeof password !== 'string' || | |||
| 87 | + | (!clearanceCookie && typeof turnstileToken !== 'string') | |||
| 88 | + | ) { | |||
| 89 | + | log.warn('Missing or invalid input fields', { | |||
| 72 | hasUsername: typeof username === 'string', | 90 | hasUsername: typeof username === 'string', | ||
| 73 | hasPassword: typeof password === 'string', | 91 | hasPassword: typeof password === 'string', | ||
| 74 | hasTurnstileToken: typeof turnstileToken === 'string' | 92 | hasTurnstileToken: typeof turnstileToken === 'string' | ||
| 75 | }); | 93 | }); | ||
| 76 | - | return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), { status: 400 }); | 94 | + | return new Response( |
| 95 | + | JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), | |||
| 96 | + | { status: 400 } | |||
| 97 | + | ); | |||
| 77 | } | 98 | } | ||
| 78 | 99 | ||||
| 79 | - | // NEW: Check if a clearance cookie exists | 100 | + | // Check for clearance cookie before validating Turnstile token. |
| 80 | - | const cookieHeader = request.headers.get('cookie'); | |||
| 81 | - | const clearanceCookie = getClearanceCookie(cookieHeader); | |||
| 82 | let isValidTurnstile = false; | 101 | let isValidTurnstile = false; | ||
| 83 | - | ||||
| 84 | if (clearanceCookie) { | 102 | if (clearanceCookie) { | ||
| 85 | - | // Optional: Validate the clearance cookie if needed. | |||
| 86 | log.debug('Clearance cookie detected; bypassing token challenge.'); | 103 | log.debug('Clearance cookie detected; bypassing token challenge.'); | ||
| 87 | isValidTurnstile = true; | 104 | isValidTurnstile = true; | ||
| 88 | } else { | 105 | } else { | ||
| 89 | - | // Fallback: Validate the turnstile token | 106 | + | isValidTurnstile = await validateTurnstileToken(turnstileToken as string, ip); |
| 90 | - | isValidTurnstile = await validateTurnstileToken(turnstileToken, ip); | |||
| 91 | } | 107 | } | ||
| 92 | 108 | ||||
| 93 | if (!isValidTurnstile) { | 109 | if (!isValidTurnstile) { | ||
| 94 | log.warn('Invalid Turnstile token', { maskedIp }); | 110 | log.warn('Invalid Turnstile token', { maskedIp }); | ||
| 95 | - | return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError), { status: 400 }); | 111 | + | return new Response( |
| 112 | + | JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError), | |||
| 113 | + | { status: 400 } | |||
| 114 | + | ); | |||
| 96 | } | 115 | } | ||
| 97 | 116 | ||||
| 98 | // Find user by username | 117 | // Find user by username | ||