detached f62d84a

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 UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The 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 }) => {
157157 return new Response(JSON.stringify({ error: 'User registration failed. Possibly user already exists.' } as RegisterResponseError), { status: 409 });
158158 }
159159
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+ );
161171 };
@@ -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 }) => {
4141
4242 if (request.method !== 'POST') {
4343 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+ );
4548 }
4649
4750 // Get the IP address for rate limiting and Turnstile validation
4851 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('.');
5053 const now = Date.now();
5154 const attemptData = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 };
5255
53- // If the user has failed three or more times, apply exponential backoff
5456 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;
5658 log.warn('Rate limit exceeded', { maskedIp, delay });
5759 await sleep(delay);
5860 }
@@ -62,37 +64,54 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
6264 body = await request.json();
6365 } catch {
6466 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+ );
6671 }
6772
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);
6976
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', {
7290 hasUsername: typeof username === 'string',
7391 hasPassword: typeof password === 'string',
7492 hasTurnstileToken: typeof turnstileToken === 'string'
7593 });
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+ );
7798 }
7899
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.
82101 let isValidTurnstile = false;
83-
84102 if (clearanceCookie) {
85- // Optional: Validate the clearance cookie if needed.
86103 log.debug('Clearance cookie detected; bypassing token challenge.');
87104 isValidTurnstile = true;
88105 } else {
89- // Fallback: Validate the turnstile token
90- isValidTurnstile = await validateTurnstileToken(turnstileToken, ip);
106+ isValidTurnstile = await validateTurnstileToken(turnstileToken as string, ip);
91107 }
92108
93109 if (!isValidTurnstile) {
94110 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+ );
96115 }
97116
98117 // 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 validation50 // 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 milliseconds57+ 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 exists100+ // 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 token106+ 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 username117 // Find user by username

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close