detached 00c1a2f

Merge pull request #98 from kitpdr/fix/security-and-deps

fix: security hardening, dependency updates, and UI fixes
commit
00c1a2faf20794b12c1b22e0afdb116537498ad3
author
KiT · · 2026-02-16 12:40 UTC
committer
GitHub · · 2026-02-16 12:40 UTC
merge of
818aa54 + 192bd59 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
12 files · +1009 -1018
M .gitignore +4 -1
@@ -27,4 +27,7 @@ docs/
2727 .serena/
2828
2929 # Migration files (Turso to Convex)
30-migration/⏎̸
30+migration/
31+
32+# Plans
33+docs/plans/⏎̸
@@ -27,4 +27,7 @@ docs/
27 .serena/27 .serena/
28 28
29 # Migration files (Turso to Convex)29 # Migration files (Turso to Convex)
30-migration/⏎̸30+migration/
31+
32+# Plans
33+docs/plans/⏎̸
M eslint.config.js +5 -0
@@ -32,6 +32,11 @@ export default ts.config(
3232 parserOptions: {
3333 parser: ts.parser
3434 }
35+ },
36+ rules: {
37+ // False positive with Svelte 5 $bindable() — ESLint 10 flags the
38+ // destructuring assignment as useless but Svelte's compiler uses it.
39+ 'no-useless-assignment': 'off'
3540 }
3641 },
3742 {
@@ -32,6 +32,11 @@ export default ts.config(
32 parserOptions: {32 parserOptions: {
33 parser: ts.parser33 parser: ts.parser
34 }34 }
35+ },
36+ rules: {
37+ // False positive with Svelte 5 $bindable() — ESLint 10 flags the
38+ // destructuring assignment as useless but Svelte's compiler uses it.
39+ 'no-useless-assignment': 'off'
35 }40 }
36 },41 },
37 {42 {
M package.json +23 -23
@@ -23,47 +23,47 @@
2323 "db:flush": "tsx utils/db-flush.ts"
2424 },
2525 "devDependencies": {
26- "@eslint/compat": "^2.0.1",
27- "@eslint/js": "^9.39.2",
28- "@sveltejs/adapter-cloudflare": "^7.2.5",
29- "@sveltejs/kit": "^2.50.0",
26+ "@eslint/compat": "^2.0.2",
27+ "@eslint/js": "^10.0.1",
28+ "@sveltejs/adapter-cloudflare": "^7.2.7",
29+ "@sveltejs/kit": "^2.52.0",
3030 "@sveltejs/vite-plugin-svelte": "^6.2.4",
3131 "@tailwindcss/cli": "^4.1.18",
3232 "@tailwindcss/forms": "^0.5.11",
3333 "@tailwindcss/typography": "^0.5.19",
3434 "@tailwindcss/vite": "^4.1.18",
35- "@types/node": "^25.0.9",
36- "autoprefixer": "^10.4.23",
37- "drizzle-kit": "^0.31.8",
38- "eslint": "^9.39.2",
35+ "@types/node": "^25.2.3",
36+ "autoprefixer": "^10.4.24",
37+ "drizzle-kit": "^0.31.9",
38+ "eslint": "^10.0.0",
3939 "eslint-config-prettier": "^10.1.8",
40- "eslint-plugin-svelte": "^3.14.0",
41- "globals": "^17.0.0",
42- "jsdom": "^27.4.0",
43- "prettier": "^3.8.0",
40+ "eslint-plugin-svelte": "^3.15.0",
41+ "globals": "^17.3.0",
42+ "jsdom": "^28.1.0",
43+ "prettier": "^3.8.1",
4444 "prettier-plugin-svelte": "^3.4.1",
4545 "prettier-plugin-tailwindcss": "^0.7.2",
46- "svelte": "^5.46.4",
47- "svelte-check": "^4.3.5",
46+ "svelte": "^5.51.2",
47+ "svelte-check": "^4.4.0",
4848 "tailwindcss": "^4.1.18",
4949 "terser": "^5.46.0",
5050 "tsx": "^4.21.0",
5151 "tw-animate-css": "^1.4.0",
5252 "typescript": "^5.9.3",
53- "typescript-eslint": "^8.53.0",
53+ "typescript-eslint": "^8.55.0",
5454 "vite": "^7.3.1",
55- "vitest": "^4.0.17",
56- "wrangler": "^4.59.2"
55+ "vitest": "^4.0.18",
56+ "wrangler": "^4.65.0"
5757 },
5858 "dependencies": {
5959 "@libsql/client": "^0.17.0",
60- "@lucide/svelte": "^0.562.0",
61- "convex": "^1.31.5",
62- "convex-helpers": "^0.1.111",
60+ "@lucide/svelte": "^0.564.0",
61+ "convex": "^1.31.7",
62+ "convex-helpers": "^0.1.112",
6363 "convex-svelte": "^0.0.12",
64- "dotenv": "^17.2.3",
64+ "dotenv": "^17.3.1",
6565 "drizzle-orm": "^0.45.1",
66- "pino": "^10.2.0",
66+ "pino": "^10.3.1",
6767 "pino-pretty": "^13.1.3",
6868 "rehype": "^13.0.2",
6969 "rehype-sanitize": "^6.0.0",
@@ -73,7 +73,7 @@
7373 "unified": "^11.0.5",
7474 "uuid": "^13.0.0",
7575 "xp.css": "^0.2.6",
76- "zod": "^4.3.5"
76+ "zod": "^4.3.6"
7777 },
7878 "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501"
7979 }
@@ -23,47 +23,47 @@
23 "db:flush": "tsx utils/db-flush.ts"23 "db:flush": "tsx utils/db-flush.ts"
24 },24 },
25 "devDependencies": {25 "devDependencies": {
26- "@eslint/compat": "^2.0.1",26+ "@eslint/compat": "^2.0.2",
27- "@eslint/js": "^9.39.2",27+ "@eslint/js": "^10.0.1",
28- "@sveltejs/adapter-cloudflare": "^7.2.5",28+ "@sveltejs/adapter-cloudflare": "^7.2.7",
29- "@sveltejs/kit": "^2.50.0",29+ "@sveltejs/kit": "^2.52.0",
30 "@sveltejs/vite-plugin-svelte": "^6.2.4",30 "@sveltejs/vite-plugin-svelte": "^6.2.4",
31 "@tailwindcss/cli": "^4.1.18",31 "@tailwindcss/cli": "^4.1.18",
32 "@tailwindcss/forms": "^0.5.11",32 "@tailwindcss/forms": "^0.5.11",
33 "@tailwindcss/typography": "^0.5.19",33 "@tailwindcss/typography": "^0.5.19",
34 "@tailwindcss/vite": "^4.1.18",34 "@tailwindcss/vite": "^4.1.18",
35- "@types/node": "^25.0.9",35+ "@types/node": "^25.2.3",
36- "autoprefixer": "^10.4.23",36+ "autoprefixer": "^10.4.24",
37- "drizzle-kit": "^0.31.8",37+ "drizzle-kit": "^0.31.9",
38- "eslint": "^9.39.2",38+ "eslint": "^10.0.0",
39 "eslint-config-prettier": "^10.1.8",39 "eslint-config-prettier": "^10.1.8",
40- "eslint-plugin-svelte": "^3.14.0",40+ "eslint-plugin-svelte": "^3.15.0",
41- "globals": "^17.0.0",41+ "globals": "^17.3.0",
42- "jsdom": "^27.4.0",42+ "jsdom": "^28.1.0",
43- "prettier": "^3.8.0",43+ "prettier": "^3.8.1",
44 "prettier-plugin-svelte": "^3.4.1",44 "prettier-plugin-svelte": "^3.4.1",
45 "prettier-plugin-tailwindcss": "^0.7.2",45 "prettier-plugin-tailwindcss": "^0.7.2",
46- "svelte": "^5.46.4",46+ "svelte": "^5.51.2",
47- "svelte-check": "^4.3.5",47+ "svelte-check": "^4.4.0",
48 "tailwindcss": "^4.1.18",48 "tailwindcss": "^4.1.18",
49 "terser": "^5.46.0",49 "terser": "^5.46.0",
50 "tsx": "^4.21.0",50 "tsx": "^4.21.0",
51 "tw-animate-css": "^1.4.0",51 "tw-animate-css": "^1.4.0",
52 "typescript": "^5.9.3",52 "typescript": "^5.9.3",
53- "typescript-eslint": "^8.53.0",53+ "typescript-eslint": "^8.55.0",
54 "vite": "^7.3.1",54 "vite": "^7.3.1",
55- "vitest": "^4.0.17",55+ "vitest": "^4.0.18",
56- "wrangler": "^4.59.2"56+ "wrangler": "^4.65.0"
57 },57 },
58 "dependencies": {58 "dependencies": {
59 "@libsql/client": "^0.17.0",59 "@libsql/client": "^0.17.0",
60- "@lucide/svelte": "^0.562.0",60+ "@lucide/svelte": "^0.564.0",
61- "convex": "^1.31.5",61+ "convex": "^1.31.7",
62- "convex-helpers": "^0.1.111",62+ "convex-helpers": "^0.1.112",
63 "convex-svelte": "^0.0.12",63 "convex-svelte": "^0.0.12",
64- "dotenv": "^17.2.3",64+ "dotenv": "^17.3.1",
65 "drizzle-orm": "^0.45.1",65 "drizzle-orm": "^0.45.1",
66- "pino": "^10.2.0",66+ "pino": "^10.3.1",
67 "pino-pretty": "^13.1.3",67 "pino-pretty": "^13.1.3",
68 "rehype": "^13.0.2",68 "rehype": "^13.0.2",
69 "rehype-sanitize": "^6.0.0",69 "rehype-sanitize": "^6.0.0",
@@ -73,7 +73,7 @@
73 "unified": "^11.0.5",73 "unified": "^11.0.5",
74 "uuid": "^13.0.0",74 "uuid": "^13.0.0",
75 "xp.css": "^0.2.6",75 "xp.css": "^0.2.6",
76- "zod": "^4.3.5"76+ "zod": "^4.3.6"
77 },77 },
78 "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501"78 "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501"
79 }79 }
M pnpm-lock.yaml +885 -944 generated file: collapsed
M src/convex/http.ts +22 -1
@@ -5,6 +5,23 @@ import type { Id } from './_generated/dataModel';
55
66 const http = httpRouter();
77
8+// Constant-time string comparison to prevent timing attacks
9+function constantTimeCompare(a: string, b: string): boolean {
10+ if (a.length !== b.length) {
11+ // Still do work to avoid leaking length via timing
12+ let result = a.length ^ b.length;
13+ for (let i = 0; i < a.length; i++) {
14+ result |= a.charCodeAt(i) ^ a.charCodeAt(i);
15+ }
16+ return result === 0; // always false when lengths differ
17+ }
18+ let result = 0;
19+ for (let i = 0; i < a.length; i++) {
20+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
21+ }
22+ return result === 0;
23+}
24+
825 // Validate the secret header for server-to-server calls
926 function validateSecret(request: Request): boolean {
1027 const secret = request.headers.get('X-Convex-Secret');
@@ -15,7 +32,11 @@ function validateSecret(request: Request): boolean {
1532 return false;
1633 }
1734
18- return secret === expectedSecret;
35+ if (!secret) {
36+ return false;
37+ }
38+
39+ return constantTimeCompare(secret, expectedSecret);
1940 }
2041
2142 // Generic error response
@@ -5,6 +5,23 @@ import type { Id } from './_generated/dataModel';
5 5
6 const http = httpRouter();6 const http = httpRouter();
7 7
8+// Constant-time string comparison to prevent timing attacks
9+function constantTimeCompare(a: string, b: string): boolean {
10+ if (a.length !== b.length) {
11+ // Still do work to avoid leaking length via timing
12+ let result = a.length ^ b.length;
13+ for (let i = 0; i < a.length; i++) {
14+ result |= a.charCodeAt(i) ^ a.charCodeAt(i);
15+ }
16+ return result === 0; // always false when lengths differ
17+ }
18+ let result = 0;
19+ for (let i = 0; i < a.length; i++) {
20+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
21+ }
22+ return result === 0;
23+}
24+
8 // Validate the secret header for server-to-server calls25 // Validate the secret header for server-to-server calls
9 function validateSecret(request: Request): boolean {26 function validateSecret(request: Request): boolean {
10 const secret = request.headers.get('X-Convex-Secret');27 const secret = request.headers.get('X-Convex-Secret');
@@ -15,7 +32,11 @@ function validateSecret(request: Request): boolean {
15 return false;32 return false;
16 }33 }
17 34
18- return secret === expectedSecret;35+ if (!secret) {
36+ return false;
37+ }
38+
39+ return constantTimeCompare(secret, expectedSecret);
19 }40 }
20 41
21 // Generic error response42 // Generic error response
M src/convex/queries.ts +2 -1
@@ -172,8 +172,9 @@ export const getMessages = authQuery({
172172 limit: v.optional(v.number())
173173 },
174174 handler: async (ctx, args) => {
175+ const MAX_AUTH_MESSAGE_LIMIT = 200;
175176 const rawLimit = args.limit ?? 100;
176- const limit = Math.max(1, Math.floor(rawLimit));
177+ const limit = Math.max(1, Math.min(MAX_AUTH_MESSAGE_LIMIT, Math.floor(rawLimit)));
177178 const query = ctx.db
178179 .query('messages')
179180 .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))
@@ -172,8 +172,9 @@ export const getMessages = authQuery({
172 limit: v.optional(v.number())172 limit: v.optional(v.number())
173 },173 },
174 handler: async (ctx, args) => {174 handler: async (ctx, args) => {
175+ const MAX_AUTH_MESSAGE_LIMIT = 200;
175 const rawLimit = args.limit ?? 100;176 const rawLimit = args.limit ?? 100;
176- const limit = Math.max(1, Math.floor(rawLimit));177+ const limit = Math.max(1, Math.min(MAX_AUTH_MESSAGE_LIMIT, Math.floor(rawLimit)));
177 const query = ctx.db178 const query = ctx.db
178 .query('messages')179 .query('messages')
179 .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))180 .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))
M src/hooks.server.ts +21 -13
@@ -15,6 +15,7 @@ function addSecurityHeaders(response: Response): Response {
1515 headers.set('X-Content-Type-Options', 'nosniff');
1616 headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
1717 headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
18+ headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
1819
1920 return new Response(response.body, {
2021 status: response.status,
@@ -86,21 +87,28 @@ export const handle: Handle = async ({ event, resolve }) => {
8687 const origin = event.request.headers.get('origin');
8788 const host = event.request.headers.get('host');
8889
89- // Block cross-origin requests (origin header is present but doesn't match host)
90- if (origin) {
91- try {
92- const originUrl = new URL(origin);
93- if (originUrl.host !== host) {
94- log.warn('CSRF check failed - origin mismatch', {
95- origin: originUrl.host,
96- host
97- });
98- return new Response('Forbidden', { status: 403 });
99- }
100- } catch {
101- log.warn('CSRF check failed - invalid origin', { origin });
90+ if (!origin) {
91+ // Browsers always send Origin on state-changing requests.
92+ // Missing Origin means non-browser client (curl, scripts).
93+ log.warn('CSRF check failed - missing origin header', {
94+ method: event.request.method,
95+ path: event.url.pathname
96+ });
97+ return new Response('Forbidden', { status: 403 });
98+ }
99+
100+ try {
101+ const originUrl = new URL(origin);
102+ if (originUrl.host !== host) {
103+ log.warn('CSRF check failed - origin mismatch', {
104+ origin: originUrl.host,
105+ host
106+ });
102107 return new Response('Forbidden', { status: 403 });
103108 }
109+ } catch {
110+ log.warn('CSRF check failed - invalid origin', { origin });
111+ return new Response('Forbidden', { status: 403 });
104112 }
105113 }
106114
@@ -15,6 +15,7 @@ function addSecurityHeaders(response: Response): Response {
15 headers.set('X-Content-Type-Options', 'nosniff');15 headers.set('X-Content-Type-Options', 'nosniff');
16 headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');16 headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
17 headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');17 headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
18+ headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
18 19
19 return new Response(response.body, {20 return new Response(response.body, {
20 status: response.status,21 status: response.status,
@@ -86,21 +87,28 @@ export const handle: Handle = async ({ event, resolve }) => {
86 const origin = event.request.headers.get('origin');87 const origin = event.request.headers.get('origin');
87 const host = event.request.headers.get('host');88 const host = event.request.headers.get('host');
88 89
89- // Block cross-origin requests (origin header is present but doesn't match host)90+ if (!origin) {
90- if (origin) {91+ // Browsers always send Origin on state-changing requests.
91- try {92+ // Missing Origin means non-browser client (curl, scripts).
92- const originUrl = new URL(origin);93+ log.warn('CSRF check failed - missing origin header', {
93- if (originUrl.host !== host) {94+ method: event.request.method,
94- log.warn('CSRF check failed - origin mismatch', {95+ path: event.url.pathname
95- origin: originUrl.host,96+ });
96- host97+ return new Response('Forbidden', { status: 403 });
97- });98+ }
98- return new Response('Forbidden', { status: 403 });99+
99- }100+ try {
100- } catch {101+ const originUrl = new URL(origin);
101- log.warn('CSRF check failed - invalid origin', { origin });102+ if (originUrl.host !== host) {
103+ log.warn('CSRF check failed - origin mismatch', {
104+ origin: originUrl.host,
105+ host
106+ });
102 return new Response('Forbidden', { status: 403 });107 return new Response('Forbidden', { status: 403 });
103 }108 }
109+ } catch {
110+ log.warn('CSRF check failed - invalid origin', { origin });
111+ return new Response('Forbidden', { status: 403 });
104 }112 }
105 }113 }
106 114
M src/lib/components/chat-room.svelte +4 -3
@@ -374,8 +374,8 @@
374374 }
375375 });
376376
377- // Auto-scroll to bottom when new messages arrive (Svelte 5 recommended pattern)
378- $effect.pre(() => {
377+ // Auto-scroll to bottom when new messages arrive
378+ $effect(() => {
379379 if (!browser || !chatArea) return;
380380
381381 // Reference visibleMessages so effect re-runs when messages change
@@ -391,7 +391,8 @@
391391 // Scroll to bottom on initial load OR when messages update (if user was near bottom)
392392 if (!hasInitialScrolled || (hasMessageUpdate && isNearBottom)) {
393393 hasInitialScrolled = true;
394- tick().then(() => {
394+ // Use rAF to ensure browser has completed layout after DOM update
395+ requestAnimationFrame(() => {
395396 if (chatArea) {
396397 chatArea.scrollTo(0, chatArea.scrollHeight);
397398 }
@@ -374,8 +374,8 @@
374 }374 }
375 });375 });
376 376
377- // Auto-scroll to bottom when new messages arrive (Svelte 5 recommended pattern)377+ // Auto-scroll to bottom when new messages arrive
378- $effect.pre(() => {378+ $effect(() => {
379 if (!browser || !chatArea) return;379 if (!browser || !chatArea) return;
380 380
381 // Reference visibleMessages so effect re-runs when messages change381 // Reference visibleMessages so effect re-runs when messages change
@@ -391,7 +391,8 @@
391 // Scroll to bottom on initial load OR when messages update (if user was near bottom)391 // Scroll to bottom on initial load OR when messages update (if user was near bottom)
392 if (!hasInitialScrolled || (hasMessageUpdate && isNearBottom)) {392 if (!hasInitialScrolled || (hasMessageUpdate && isNearBottom)) {
393 hasInitialScrolled = true;393 hasInitialScrolled = true;
394- tick().then(() => {394+ // Use rAF to ensure browser has completed layout after DOM update
395+ requestAnimationFrame(() => {
395 if (chatArea) {396 if (chatArea) {
396 chatArea.scrollTo(0, chatArea.scrollHeight);397 chatArea.scrollTo(0, chatArea.scrollHeight);
397 }398 }
M src/routes/api/register/+server.ts +22 -15
@@ -10,23 +10,27 @@ const log = createLogger('register-server');
1010 // In-memory map to track failed captcha attempts per IP
1111 const captchaAttempts = new Map<string, { count: number; lastAttempt: number }>();
1212
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);
2224 }
23- },
24- 60 * 60 * 1000
25-);
25+ }
26+}
2627
27-export const POST: RequestHandler = async ({ request }) => {
28+export const POST: RequestHandler = async ({ request, getClientAddress }) => {
2829 log.debug('New registration attempt received');
2930
31+ // Lazy cleanup on each request (replaces setInterval for Cloudflare compatibility)
32+ cleanupOldCaptchaAttempts();
33+
3034 if (request.method !== 'POST') {
3135 log.warn('Invalid method used', { method: request.method });
3236 return new Response(JSON.stringify({ error: 'Method Not Allowed' } as RegisterResponseError), {
@@ -35,7 +39,7 @@ export const POST: RequestHandler = async ({ request }) => {
3539 }
3640
3741 // Get the IP address for rate limiting and Turnstile validation
38- const ip = request.headers.get('x-forwarded-for') || 'unknown';
42+ const ip = getClientAddress();
3943 const maskedIp = ip
4044 .split('.')
4145 .map((octet, idx) => (idx < 3 ? 'xxx' : octet))
@@ -81,6 +85,9 @@ export const POST: RequestHandler = async ({ request }) => {
8185 }
8286
8387 // 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).
8491 const normalizedAnswer = captchaAnswer.trim().toLowerCase();
8592 if (normalizedAnswer !== 'point de rencontre') {
8693 attemptData.count++;
@@ -98,7 +105,7 @@ export const POST: RequestHandler = async ({ request }) => {
98105
99106 // Trim input values (validation already done by Zod)
100107 const username = suUsername.trim();
101- const password = suPassword.trim();
108+ const password = suPassword;
102109
103110 // Check if nickname already exists using Convex
104111 const existingUser = await users.getByNickname(username);
@@ -10,23 +10,27 @@ const log = createLogger('register-server');
10 // In-memory map to track failed captcha attempts per IP10 // 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 hour13+// 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 * 100026+}
25-);
26 27
27-export const POST: RequestHandler = async ({ request }) => {28+export const POST: RequestHandler = async ({ request, getClientAddress }) => {
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), {
@@ -35,7 +39,7 @@ export const POST: RequestHandler = async ({ request }) => {
35 }39 }
36 40
37 // Get the IP address for rate limiting and Turnstile validation41 // Get the IP address for rate limiting and Turnstile validation
38- const ip = request.headers.get('x-forwarded-for') || 'unknown';42+ const ip = getClientAddress();
39 const maskedIp = ip43 const maskedIp = ip
40 .split('.')44 .split('.')
41 .map((octet, idx) => (idx < 3 ? 'xxx' : octet))45 .map((octet, idx) => (idx < 3 ? 'xxx' : octet))
@@ -81,6 +85,9 @@ export const POST: RequestHandler = async ({ request }) => {
81 }85 }
82 86
83 // Validate PDR captcha87 // 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++;
@@ -98,7 +105,7 @@ export const POST: RequestHandler = async ({ request }) => {
98 105
99 // Trim input values (validation already done by Zod)106 // Trim input values (validation already done by Zod)
100 const username = suUsername.trim();107 const username = suUsername.trim();
101- const password = suPassword.trim();108+ const password = suPassword;
102 109
103 // Check if nickname already exists using Convex110 // Check if nickname already exists using Convex
104 const existingUser = await users.getByNickname(username);111 const existingUser = await users.getByNickname(username);
M src/routes/api/session/login/+server.ts +4 -7
@@ -31,7 +31,7 @@ function cleanupOldAttempts() {
3131 }
3232 }
3333
34-export const POST: RequestHandler = async ({ request, cookies }) => {
34+export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
3535 log.debug('New login attempt received');
3636
3737 // Lazy cleanup on each request (replaces setInterval for Cloudflare compatibility)
@@ -44,11 +44,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
4444 });
4545 }
4646
47- // Get the IP address for rate limiting and Turnstile validation
48- // Prefer Cloudflare's CF-Connecting-IP header, then x-forwarded-for (first IP), then fallback
49- const cfConnectingIp = request.headers.get('cf-connecting-ip');
50- const forwardedFor = request.headers.get('x-forwarded-for') || '';
51- const ip = cfConnectingIp || forwardedFor.split(',')[0]?.trim() || 'unknown';
47+ // Get the IP address for rate limiting
48+ const ip = getClientAddress();
5249 const maskedIp = ip
5350 .split('.')
5451 .map((octet, idx) => (idx < 3 ? 'xxx' : octet))
@@ -115,7 +112,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
115112
116113 // Verify password
117114 try {
118- const isValid = await verifyPassword(password.trim(), user.password);
115+ const isValid = await verifyPassword(password, user.password);
119116 if (!isValid) {
120117 attemptData.count++;
121118 attemptData.lastAttempt = now;
@@ -31,7 +31,7 @@ function cleanupOldAttempts() {
31 }31 }
32 }32 }
33 33
34-export const POST: RequestHandler = async ({ request, cookies }) => {34+export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
35 log.debug('New login attempt received');35 log.debug('New login attempt received');
36 36
37 // Lazy cleanup on each request (replaces setInterval for Cloudflare compatibility)37 // Lazy cleanup on each request (replaces setInterval for Cloudflare compatibility)
@@ -44,11 +44,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
44 });44 });
45 }45 }
46 46
47- // Get the IP address for rate limiting and Turnstile validation47+ // Get the IP address for rate limiting
48- // Prefer Cloudflare's CF-Connecting-IP header, then x-forwarded-for (first IP), then fallback48+ const ip = getClientAddress();
49- const cfConnectingIp = request.headers.get('cf-connecting-ip');
50- const forwardedFor = request.headers.get('x-forwarded-for') || '';
51- const ip = cfConnectingIp || forwardedFor.split(',')[0]?.trim() || 'unknown';
52 const maskedIp = ip49 const maskedIp = ip
53 .split('.')50 .split('.')
54 .map((octet, idx) => (idx < 3 ? 'xxx' : octet))51 .map((octet, idx) => (idx < 3 ? 'xxx' : octet))
@@ -115,7 +112,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
115 112
116 // Verify password113 // Verify password
117 try {114 try {
118- const isValid = await verifyPassword(password.trim(), user.password);115+ const isValid = await verifyPassword(password, user.password);
119 if (!isValid) {116 if (!isValid) {
120 attemptData.count++;117 attemptData.count++;
121 attemptData.lastAttempt = now;118 attemptData.lastAttempt = now;
M src/routes/api/user/text-preferences/+server.ts +16 -9
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
33 import convex from '$lib/db/convex.server';
44 import { DEFAULT_USER_PREFERENCES, type UserTextPreferences } from '$lib/types/text-formatting';
55 import { validateSessionToken } from '$lib/api/session.server';
6+import { sanitizeStyleData } from '$lib/validation/text-formatting';
67
78 const isKitHttpError = (err: unknown): err is { status: number; body: unknown } => {
89 return typeof err === 'object' && err !== null && 'status' in err && 'body' in err;
@@ -73,21 +74,27 @@ export const POST: RequestHandler = async ({ cookies, request }) => {
7374
7475 // Parse request body
7576 const body = await request.json();
76- const { defaultStyle, allowFormatting, maxMessageLength } = body as UserTextPreferences;
77-
78- // Validate required fields
79- if (
80- !defaultStyle ||
81- typeof allowFormatting !== 'boolean' ||
82- typeof maxMessageLength !== 'number'
83- ) {
77+ const { allowFormatting, maxMessageLength } = body as {
78+ allowFormatting: unknown;
79+ maxMessageLength: unknown;
80+ defaultStyle: unknown;
81+ };
82+
83+ // Validate types
84+ if (typeof allowFormatting !== 'boolean' || typeof maxMessageLength !== 'number') {
8485 throw error(400, 'Invalid preferences data');
8586 }
8687
88+ // Validate and sanitize style data through existing Zod schema
89+ const validatedStyle = sanitizeStyleData(body.defaultStyle);
90+ if (!validatedStyle) {
91+ throw error(400, 'Invalid style data');
92+ }
93+
8794 // Save preferences via Convex
8895 const result = await convex.textPreferences.save(
8996 user.id,
90- defaultStyle,
97+ validatedStyle,
9198 allowFormatting,
9299 maxMessageLength
93100 );
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
3 import convex from '$lib/db/convex.server';3 import convex from '$lib/db/convex.server';
4 import { DEFAULT_USER_PREFERENCES, type UserTextPreferences } from '$lib/types/text-formatting';4 import { DEFAULT_USER_PREFERENCES, type UserTextPreferences } from '$lib/types/text-formatting';
5 import { validateSessionToken } from '$lib/api/session.server';5 import { validateSessionToken } from '$lib/api/session.server';
6+import { sanitizeStyleData } from '$lib/validation/text-formatting';
6 7
7 const isKitHttpError = (err: unknown): err is { status: number; body: unknown } => {8 const isKitHttpError = (err: unknown): err is { status: number; body: unknown } => {
8 return typeof err === 'object' && err !== null && 'status' in err && 'body' in err;9 return typeof err === 'object' && err !== null && 'status' in err && 'body' in err;
@@ -73,21 +74,27 @@ export const POST: RequestHandler = async ({ cookies, request }) => {
73 74
74 // Parse request body75 // Parse request body
75 const body = await request.json();76 const body = await request.json();
76- const { defaultStyle, allowFormatting, maxMessageLength } = body as UserTextPreferences;77+ const { allowFormatting, maxMessageLength } = body as {
77-78+ allowFormatting: unknown;
78- // Validate required fields79+ maxMessageLength: unknown;
79- if (80+ defaultStyle: unknown;
80- !defaultStyle ||81+ };
81- typeof allowFormatting !== 'boolean' ||82+
82- typeof maxMessageLength !== 'number'83+ // Validate types
83- ) {84+ if (typeof allowFormatting !== 'boolean' || typeof maxMessageLength !== 'number') {
84 throw error(400, 'Invalid preferences data');85 throw error(400, 'Invalid preferences data');
85 }86 }
86 87
88+ // Validate and sanitize style data through existing Zod schema
89+ const validatedStyle = sanitizeStyleData(body.defaultStyle);
90+ if (!validatedStyle) {
91+ throw error(400, 'Invalid style data');
92+ }
93+
87 // Save preferences via Convex94 // Save preferences via Convex
88 const result = await convex.textPreferences.save(95 const result = await convex.textPreferences.save(
89 user.id,96 user.id,
90- defaultStyle,97+ validatedStyle,
91 allowFormatting,98 allowFormatting,
92 maxMessageLength99 maxMessageLength
93 );100 );
M svelte.config.js +1 -1
@@ -16,7 +16,7 @@ const config = {
1616 mode: 'auto',
1717 directives: {
1818 'default-src': ['self'],
19- 'script-src': ['self', 'https://challenges.cloudflare.com', 'unsafe-eval'],
19+ 'script-src': ['self', 'https://challenges.cloudflare.com'],
2020 'style-src': ['self', 'unsafe-inline'],
2121 'img-src': ['self', 'data:', 'blob:'],
2222 'connect-src': [
@@ -16,7 +16,7 @@ const config = {
16 mode: 'auto',16 mode: 'auto',
17 directives: {17 directives: {
18 'default-src': ['self'],18 'default-src': ['self'],
19- 'script-src': ['self', 'https://challenges.cloudflare.com', 'unsafe-eval'],19+ 'script-src': ['self', 'https://challenges.cloudflare.com'],
20 'style-src': ['self', 'unsafe-inline'],20 'style-src': ['self', 'unsafe-inline'],
21 'img-src': ['self', 'data:', 'blob:'],21 'img-src': ['self', 'data:', 'blob:'],
22 'connect-src': [22 'connect-src': [

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