detached 059b851

feat(security): Enhance client-side signup validation and login security checks

commit
059b851be41656c20de9a5a9c8c1300a6d67a810
author
Kit · · 2025-02-18 07:02 UTC
parent
38bffed
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
2 files · +74 -12
M src/lib/components/aim-login.svelte +52 -9
@@ -89,16 +89,59 @@
8989 }
9090 }
9191
92+ // New: Create a helper function for client-side validations based on server rules.
93+ function validateSignupData(): string | null {
94+ // Trim values
95+ const username = suUsername.trim();
96+ const password = suPassword.trim();
97+ const confirmPasswordValue = suConfirmPassword.trim();
98+
99+ if (!username || !password || !confirmPasswordValue) {
100+ return 'All fields must be filled';
101+ }
102+
103+ if (username.length < 3) {
104+ return 'Username must be at least 3 characters';
105+ }
106+
107+ if (username.length > 32) {
108+ return 'Username must be at most 32 characters';
109+ }
110+
111+ // Only allow letters, numbers, underscores, and dashes in the username.
112+ const usernameRegex = /^[a-zA-Z0-9_-]+$/;
113+ if (!usernameRegex.test(username)) {
114+ return 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.';
115+ }
116+
117+ if (password !== confirmPasswordValue) {
118+ return 'Passwords do not match';
119+ }
120+
121+ if (password.length < 8 || password.length > 64) {
122+ return 'Password must be between 8 and 64 characters';
123+ }
124+
125+ // All checks passed.
126+ return null;
127+ }
128+
92129 async function handleSignupSubmit() {
93130 signupError = '';
94131 signupStatus = 'loading';
95- if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer || !suTurnstileToken) {
96- signupError = 'Please complete all fields including the security check';
132+
133+ // Client-side validations before submitting the form.
134+ const validationError = validateSignupData();
135+ if (validationError) {
136+ signupError = validationError;
97137 signupStatus = 'error';
98138 return;
99139 }
100- if (suPassword !== suConfirmPassword) {
101- signupError = 'Passwords do not match';
140+
141+ // Additional check for captcha and Turnstile token,
142+ // in case those fields are not filled.
143+ if (!captchaAnswer || !suTurnstileToken) {
144+ signupError = 'Please complete all fields including the security check';
102145 signupStatus = 'error';
103146 return;
104147 }
@@ -108,10 +151,10 @@
108151 const res = await fetch('/api/register', {
109152 method: 'POST',
110153 headers: { 'Content-Type': 'application/json' },
111- body: JSON.stringify({
112- suUsername,
113- suPassword,
114- suConfirmPassword,
154+ body: JSON.stringify({
155+ suUsername,
156+ suPassword,
157+ suConfirmPassword,
115158 captchaAnswer,
116159 turnstileToken: suTurnstileToken
117160 })
@@ -127,7 +170,7 @@
127170 console.debug("Registration succeeded:", data);
128171 signupStatus = 'success';
129172
130- // Auto login after registration
173+ // Auto login after registration.
131174 await handleAutoLoginSignup();
132175 return;
133176 } catch (err) {
@@ -89,16 +89,59 @@
89 }89 }
90 }90 }
91 91
92+ // New: Create a helper function for client-side validations based on server rules.
93+ function validateSignupData(): string | null {
94+ // Trim values
95+ const username = suUsername.trim();
96+ const password = suPassword.trim();
97+ const confirmPasswordValue = suConfirmPassword.trim();
98+
99+ if (!username || !password || !confirmPasswordValue) {
100+ return 'All fields must be filled';
101+ }
102+
103+ if (username.length < 3) {
104+ return 'Username must be at least 3 characters';
105+ }
106+
107+ if (username.length > 32) {
108+ return 'Username must be at most 32 characters';
109+ }
110+
111+ // Only allow letters, numbers, underscores, and dashes in the username.
112+ const usernameRegex = /^[a-zA-Z0-9_-]+$/;
113+ if (!usernameRegex.test(username)) {
114+ return 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.';
115+ }
116+
117+ if (password !== confirmPasswordValue) {
118+ return 'Passwords do not match';
119+ }
120+
121+ if (password.length < 8 || password.length > 64) {
122+ return 'Password must be between 8 and 64 characters';
123+ }
124+
125+ // All checks passed.
126+ return null;
127+ }
128+
92 async function handleSignupSubmit() {129 async function handleSignupSubmit() {
93 signupError = '';130 signupError = '';
94 signupStatus = 'loading';131 signupStatus = 'loading';
95- if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer || !suTurnstileToken) {132+
96- signupError = 'Please complete all fields including the security check';133+ // Client-side validations before submitting the form.
134+ const validationError = validateSignupData();
135+ if (validationError) {
136+ signupError = validationError;
97 signupStatus = 'error';137 signupStatus = 'error';
98 return;138 return;
99 }139 }
100- if (suPassword !== suConfirmPassword) {140+
101- signupError = 'Passwords do not match';141+ // Additional check for captcha and Turnstile token,
142+ // in case those fields are not filled.
143+ if (!captchaAnswer || !suTurnstileToken) {
144+ signupError = 'Please complete all fields including the security check';
102 signupStatus = 'error';145 signupStatus = 'error';
103 return;146 return;
104 }147 }
@@ -108,10 +151,10 @@
108 const res = await fetch('/api/register', {151 const res = await fetch('/api/register', {
109 method: 'POST',152 method: 'POST',
110 headers: { 'Content-Type': 'application/json' },153 headers: { 'Content-Type': 'application/json' },
111- body: JSON.stringify({ 154+ body: JSON.stringify({
112- suUsername, 155+ suUsername,
113- suPassword, 156+ suPassword,
114- suConfirmPassword, 157+ suConfirmPassword,
115 captchaAnswer,158 captchaAnswer,
116 turnstileToken: suTurnstileToken159 turnstileToken: suTurnstileToken
117 })160 })
@@ -127,7 +170,7 @@
127 console.debug("Registration succeeded:", data);170 console.debug("Registration succeeded:", data);
128 signupStatus = 'success';171 signupStatus = 'success';
129 172
130- // Auto login after registration173+ // Auto login after registration.
131 await handleAutoLoginSignup();174 await handleAutoLoginSignup();
132 return;175 return;
133 } catch (err) {176 } catch (err) {
M src/routes/api/session/login/+server.ts +22 -3
@@ -2,7 +2,7 @@ import type { RequestHandler } from '@sveltejs/kit';
22 import type { LoginResponseSuccess, LoginResponseError } from '$lib/types/payloads';
33 import db from '$lib/db/db.server';
44 import { users } from '$lib/db/schema';
5-import { eq } from 'drizzle-orm';
5+import { eq } from 'drizzle-orm/sql';
66 import { verifyPassword } from '$lib/utils/password';
77 import { generateSessionToken, createSession } from '$lib/api/session.server';
88 import { setSessionTokenCookie } from '$lib/api/session.cookie';
@@ -28,6 +28,13 @@ setInterval(() => {
2828 }
2929 }, 60 * 60 * 1000);
3030
31+// Helper to parse clearance cookie from headers
32+function getClearanceCookie(cookieHeader: string | null): string | null {
33+ if (!cookieHeader) return null;
34+ const match = cookieHeader.match(/cf-turnstile-clearance=([^;]+)/);
35+ return match ? match[1] : null;
36+}
37+
3138 export const POST: RequestHandler = async ({ request, cookies }) => {
3239 log.debug('New login attempt received');
3340
@@ -68,8 +75,20 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
6875 return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), { status: 400 });
6976 }
7077
71- // Validate Turnstile token
72- const isValidTurnstile = await validateTurnstileToken(turnstileToken, ip);
78+ // NEW: Check if a clearance cookie exists
79+ const cookieHeader = request.headers.get('cookie');
80+ const clearanceCookie = getClearanceCookie(cookieHeader);
81+ let isValidTurnstile = false;
82+
83+ if (clearanceCookie) {
84+ // Optional: Validate the clearance cookie if needed.
85+ log.debug('Clearance cookie detected; bypassing token challenge.');
86+ isValidTurnstile = true;
87+ } else {
88+ // Fallback: Validate the turnstile token
89+ isValidTurnstile = await validateTurnstileToken(turnstileToken, ip);
90+ }
91+
7392 if (!isValidTurnstile) {
7493 log.warn('Invalid Turnstile token', { maskedIp });
7594 return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError), { status: 400 });
@@ -2,7 +2,7 @@ import type { RequestHandler } from '@sveltejs/kit';
2 import type { LoginResponseSuccess, LoginResponseError } from '$lib/types/payloads';2 import type { LoginResponseSuccess, LoginResponseError } from '$lib/types/payloads';
3 import db from '$lib/db/db.server';3 import db from '$lib/db/db.server';
4 import { users } from '$lib/db/schema';4 import { users } from '$lib/db/schema';
5-import { eq } from 'drizzle-orm';5+import { eq } from 'drizzle-orm/sql';
6 import { verifyPassword } from '$lib/utils/password';6 import { verifyPassword } from '$lib/utils/password';
7 import { generateSessionToken, createSession } from '$lib/api/session.server';7 import { generateSessionToken, createSession } from '$lib/api/session.server';
8 import { setSessionTokenCookie } from '$lib/api/session.cookie';8 import { setSessionTokenCookie } from '$lib/api/session.cookie';
@@ -28,6 +28,13 @@ setInterval(() => {
28 }28 }
29 }, 60 * 60 * 1000);29 }, 60 * 60 * 1000);
30 30
31+// Helper to parse clearance cookie from headers
32+function getClearanceCookie(cookieHeader: string | null): string | null {
33+ if (!cookieHeader) return null;
34+ const match = cookieHeader.match(/cf-turnstile-clearance=([^;]+)/);
35+ return match ? match[1] : null;
36+}
37+
31 export const POST: RequestHandler = async ({ request, cookies }) => {38 export const POST: RequestHandler = async ({ request, cookies }) => {
32 log.debug('New login attempt received');39 log.debug('New login attempt received');
33 40
@@ -68,8 +75,20 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
68 return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), { status: 400 });75 return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), { status: 400 });
69 }76 }
70 77
71- // Validate Turnstile token78+ // NEW: Check if a clearance cookie exists
72- const isValidTurnstile = await validateTurnstileToken(turnstileToken, ip);79+ const cookieHeader = request.headers.get('cookie');
80+ const clearanceCookie = getClearanceCookie(cookieHeader);
81+ let isValidTurnstile = false;
82+
83+ if (clearanceCookie) {
84+ // Optional: Validate the clearance cookie if needed.
85+ log.debug('Clearance cookie detected; bypassing token challenge.');
86+ isValidTurnstile = true;
87+ } else {
88+ // Fallback: Validate the turnstile token
89+ isValidTurnstile = await validateTurnstileToken(turnstileToken, ip);
90+ }
91+
73 if (!isValidTurnstile) {92 if (!isValidTurnstile) {
74 log.warn('Invalid Turnstile token', { maskedIp });93 log.warn('Invalid Turnstile token', { maskedIp });
75 return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError), { status: 400 });94 return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError), { status: 400 });

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