detached 89bb389

security: fix critical auth and API vulnerabilities

- Remove password hash from session validation response (SafeUser type)
- Add constant-time password comparison to prevent timing attacks
- Return 401 for invalid sessions instead of 200 with null
- Add proper error handling to logout endpoint
- Mask session tokens in debug logs
- Add CSRF protection via Origin/Host header validation
- Add try-catch for session renewal in status endpoint
- Add JSON parsing error handling in Convex HTTP handlers
- Stop leaking internal error messages to clients
- Normalize IP address for rate limiting (CF-Connecting-IP support)
commit
89bb389302c8690f4cb8ee9394df4d942fec50cc
author
bnema · · 2026-01-18 20:51 UTC
parent
e7b86a4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +199 -74
M src/app.d.ts +2 -2
@@ -1,12 +1,12 @@
11 // See https://svelte.dev/docs/kit/types#app.d.ts
22 // for information about these interfaces
3-import type { User, Session } from '$lib/types/chat';
3+import type { SafeUser, Session } from '$lib/types/chat';
44
55 declare global {
66 namespace App {
77 // interface Error {}
88 interface Locals {
9- user: User | null;
9+ user: SafeUser | null;
1010 session: Session | null;
1111 }
1212 // interface PageData {}
@@ -1,12 +1,12 @@
1 // See https://svelte.dev/docs/kit/types#app.d.ts1 // See https://svelte.dev/docs/kit/types#app.d.ts
2 // for information about these interfaces2 // for information about these interfaces
3-import type { User, Session } from '$lib/types/chat';3+import type { SafeUser, Session } from '$lib/types/chat';
4 4
5 declare global {5 declare global {
6 namespace App {6 namespace App {
7 // interface Error {}7 // interface Error {}
8 interface Locals {8 interface Locals {
9- user: User | null;9+ user: SafeUser | null;
10 session: Session | null;10 session: Session | null;
11 }11 }
12 // interface PageData {}12 // interface PageData {}
M src/convex/http.ts +107 -39
@@ -1,6 +1,7 @@
11 import { httpRouter } from 'convex/server';
22 import { httpAction } from './_generated/server';
33 import { internal } from './_generated/api';
4+import type { Id } from './_generated/dataModel';
45
56 const http = httpRouter();
67
@@ -32,6 +33,15 @@ function errorResponse(message: string, status = 400) {
3233 });
3334 }
3435
36+// Helper function to safely parse JSON body
37+async function parseJsonBody<T>(request: Request): Promise<T | null> {
38+ try {
39+ return (await request.json()) as T;
40+ } catch {
41+ return null;
42+ }
43+}
44+
3545 // ============ USERS (server-side only) ============
3646
3747 // Get user by nickname - for login
@@ -41,8 +51,12 @@ http.route({
4151 handler: httpAction(async (ctx, request) => {
4252 if (!validateSecret(request)) return unauthorizedResponse();
4353
44- const { nickname } = await request.json();
45- const user = await ctx.runQuery(internal.usersInternal.getByNickname, { nickname });
54+ const body = await parseJsonBody<{ nickname: string }>(request);
55+ if (!body) return errorResponse('Invalid JSON', 400);
56+
57+ const user = await ctx.runQuery(internal.usersInternal.getByNickname, {
58+ nickname: body.nickname
59+ });
4660
4761 return new Response(JSON.stringify(user), {
4862 headers: { 'Content-Type': 'application/json' }
@@ -57,9 +71,14 @@ http.route({
5771 handler: httpAction(async (ctx, request) => {
5872 if (!validateSecret(request)) return unauthorizedResponse();
5973
60- const { nickname, password } = await request.json();
74+ const body = await parseJsonBody<{ nickname: string; password: string }>(request);
75+ if (!body) return errorResponse('Invalid JSON', 400);
76+
6177 try {
62- const userId = await ctx.runMutation(internal.usersInternal.create, { nickname, password });
78+ const userId = await ctx.runMutation(internal.usersInternal.create, {
79+ nickname: body.nickname,
80+ password: body.password
81+ });
6382 return new Response(JSON.stringify({ userId }), {
6483 headers: { 'Content-Type': 'application/json' }
6584 });
@@ -76,8 +95,10 @@ http.route({
7695 handler: httpAction(async (ctx, request) => {
7796 if (!validateSecret(request)) return unauthorizedResponse();
7897
79- const { id } = await request.json();
80- const user = await ctx.runQuery(internal.usersInternal.getById, { id });
98+ const body = await parseJsonBody<{ id: string }>(request);
99+ if (!body) return errorResponse('Invalid JSON', 400);
100+
101+ const user = await ctx.runQuery(internal.usersInternal.getById, { id: body.id as Id<'users'> });
81102
82103 return new Response(JSON.stringify(user), {
83104 headers: { 'Content-Type': 'application/json' }
@@ -107,11 +128,13 @@ http.route({
107128 handler: httpAction(async (ctx, request) => {
108129 if (!validateSecret(request)) return unauthorizedResponse();
109130
110- const { userId, status } = await request.json();
131+ const body = await parseJsonBody<{ userId: string; status: string }>(request);
132+ if (!body) return errorResponse('Invalid JSON', 400);
133+
111134 try {
112135 const user = await ctx.runMutation(internal.usersInternal.updateStatus, {
113- id: userId,
114- status
136+ id: body.userId as Id<'users'>,
137+ status: body.status
115138 });
116139 return new Response(JSON.stringify(user), {
117140 headers: { 'Content-Type': 'application/json' }
@@ -146,12 +169,16 @@ http.route({
146169 handler: httpAction(async (ctx, request) => {
147170 if (!validateSecret(request)) return unauthorizedResponse();
148171
149- const { tokenHash, userId, expiresAt } = await request.json();
172+ const body = await parseJsonBody<{ tokenHash: string; userId: string; expiresAt: number }>(
173+ request
174+ );
175+ if (!body) return errorResponse('Invalid JSON', 400);
176+
150177 try {
151178 const result = await ctx.runMutation(internal.sessionsInternal.create, {
152- tokenHash,
153- userId,
154- expiresAt
179+ tokenHash: body.tokenHash,
180+ userId: body.userId as Id<'users'>,
181+ expiresAt: body.expiresAt
155182 });
156183 return new Response(JSON.stringify(result), {
157184 headers: { 'Content-Type': 'application/json' }
@@ -169,8 +196,12 @@ http.route({
169196 handler: httpAction(async (ctx, request) => {
170197 if (!validateSecret(request)) return unauthorizedResponse();
171198
172- const { tokenHash } = await request.json();
173- const result = await ctx.runQuery(internal.sessionsInternal.validateAndGetUser, { tokenHash });
199+ const body = await parseJsonBody<{ tokenHash: string }>(request);
200+ if (!body) return errorResponse('Invalid JSON', 400);
201+
202+ const result = await ctx.runQuery(internal.sessionsInternal.validateAndGetUser, {
203+ tokenHash: body.tokenHash
204+ });
174205
175206 return new Response(JSON.stringify(result), {
176207 headers: { 'Content-Type': 'application/json' }
@@ -185,8 +216,10 @@ http.route({
185216 handler: httpAction(async (ctx, request) => {
186217 if (!validateSecret(request)) return unauthorizedResponse();
187218
188- const { tokenHash } = await request.json();
189- await ctx.runMutation(internal.sessionsInternal.remove, { tokenHash });
219+ const body = await parseJsonBody<{ tokenHash: string }>(request);
220+ if (!body) return errorResponse('Invalid JSON', 400);
221+
222+ await ctx.runMutation(internal.sessionsInternal.remove, { tokenHash: body.tokenHash });
190223
191224 return new Response(JSON.stringify({ success: true }), {
192225 headers: { 'Content-Type': 'application/json' }
@@ -218,8 +251,12 @@ http.route({
218251 handler: httpAction(async (ctx, request) => {
219252 if (!validateSecret(request)) return unauthorizedResponse();
220253
221- const { id } = await request.json();
222- const room = await ctx.runQuery(internal.chatRoomsInternal.getById, { id });
254+ const body = await parseJsonBody<{ id: string }>(request);
255+ if (!body) return errorResponse('Invalid JSON', 400);
256+
257+ const room = await ctx.runQuery(internal.chatRoomsInternal.getById, {
258+ id: body.id as Id<'chatRooms'>
259+ });
223260
224261 return new Response(JSON.stringify(room), {
225262 headers: { 'Content-Type': 'application/json' }
@@ -236,11 +273,15 @@ http.route({
236273 handler: httpAction(async (ctx, request) => {
237274 if (!validateSecret(request)) return unauthorizedResponse();
238275
239- const { roomId, limit, beforeTimestamp } = await request.json();
276+ const body = await parseJsonBody<{ roomId: string; limit?: number; beforeTimestamp?: number }>(
277+ request
278+ );
279+ if (!body) return errorResponse('Invalid JSON', 400);
280+
240281 const result = await ctx.runQuery(internal.messagesInternal.getByRoom, {
241- roomId,
242- limit,
243- beforeTimestamp
282+ roomId: body.roomId as Id<'chatRooms'>,
283+ limit: body.limit,
284+ beforeTimestamp: body.beforeTimestamp
244285 });
245286
246287 return new Response(JSON.stringify(result), {
@@ -256,17 +297,26 @@ http.route({
256297 handler: httpAction(async (ctx, request) => {
257298 if (!validateSecret(request)) return unauthorizedResponse();
258299
259- const { chatRoomId, senderId, content, type, styleData, hasFormatting, timestamp } =
260- await request.json();
300+ const body = await parseJsonBody<{
301+ chatRoomId: string;
302+ senderId: string;
303+ content: string;
304+ type: string;
305+ styleData?: string;
306+ hasFormatting?: boolean;
307+ timestamp?: number;
308+ }>(request);
309+ if (!body) return errorResponse('Invalid JSON', 400);
310+
261311 try {
262312 const message = await ctx.runMutation(internal.messagesInternal.send, {
263- chatRoomId,
264- senderId,
265- content,
266- type,
267- styleData,
268- hasFormatting,
269- timestamp
313+ chatRoomId: body.chatRoomId as Id<'chatRooms'>,
314+ senderId: body.senderId as Id<'users'>,
315+ content: body.content,
316+ type: body.type,
317+ styleData: body.styleData,
318+ hasFormatting: body.hasFormatting,
319+ timestamp: body.timestamp
270320 });
271321 return new Response(JSON.stringify(message), {
272322 headers: { 'Content-Type': 'application/json' }
@@ -286,9 +336,11 @@ http.route({
286336 handler: httpAction(async (ctx, request) => {
287337 if (!validateSecret(request)) return unauthorizedResponse();
288338
289- const { userId } = await request.json();
339+ const body = await parseJsonBody<{ userId: string }>(request);
340+ if (!body) return errorResponse('Invalid JSON', 400);
341+
290342 const preferences = await ctx.runQuery(internal.textPreferencesInternal.getByUserId, {
291- userId
343+ userId: body.userId as Id<'users'>
292344 });
293345
294346 return new Response(JSON.stringify(preferences), {
@@ -304,13 +356,29 @@ http.route({
304356 handler: httpAction(async (ctx, request) => {
305357 if (!validateSecret(request)) return unauthorizedResponse();
306358
307- const { userId, defaultStyle, allowFormatting, maxMessageLength } = await request.json();
359+ const body = await parseJsonBody<{
360+ userId: string;
361+ defaultStyle: {
362+ fontFamily: string;
363+ fontSize: number;
364+ color?: string;
365+ gradient?: string[];
366+ bold: boolean;
367+ italic: boolean;
368+ underline: boolean;
369+ strikethrough: boolean;
370+ };
371+ allowFormatting: boolean;
372+ maxMessageLength: number;
373+ }>(request);
374+ if (!body) return errorResponse('Invalid JSON', 400);
375+
308376 try {
309377 const result = await ctx.runMutation(internal.textPreferencesInternal.upsert, {
310- userId,
311- defaultStyle,
312- allowFormatting,
313- maxMessageLength
378+ userId: body.userId as Id<'users'>,
379+ defaultStyle: body.defaultStyle,
380+ allowFormatting: body.allowFormatting,
381+ maxMessageLength: body.maxMessageLength
314382 });
315383 return new Response(JSON.stringify({ success: true, id: result }), {
316384 headers: { 'Content-Type': 'application/json' }
@@ -1,6 +1,7 @@
1 import { httpRouter } from 'convex/server';1 import { httpRouter } from 'convex/server';
2 import { httpAction } from './_generated/server';2 import { httpAction } from './_generated/server';
3 import { internal } from './_generated/api';3 import { internal } from './_generated/api';
4+import type { Id } from './_generated/dataModel';
4 5
5 const http = httpRouter();6 const http = httpRouter();
6 7
@@ -32,6 +33,15 @@ function errorResponse(message: string, status = 400) {
32 });33 });
33 }34 }
34 35
36+// Helper function to safely parse JSON body
37+async function parseJsonBody<T>(request: Request): Promise<T | null> {
38+ try {
39+ return (await request.json()) as T;
40+ } catch {
41+ return null;
42+ }
43+}
44+
35 // ============ USERS (server-side only) ============45 // ============ USERS (server-side only) ============
36 46
37 // Get user by nickname - for login47 // Get user by nickname - for login
@@ -41,8 +51,12 @@ http.route({
41 handler: httpAction(async (ctx, request) => {51 handler: httpAction(async (ctx, request) => {
42 if (!validateSecret(request)) return unauthorizedResponse();52 if (!validateSecret(request)) return unauthorizedResponse();
43 53
44- const { nickname } = await request.json();54+ const body = await parseJsonBody<{ nickname: string }>(request);
45- const user = await ctx.runQuery(internal.usersInternal.getByNickname, { nickname });55+ if (!body) return errorResponse('Invalid JSON', 400);
56+
57+ const user = await ctx.runQuery(internal.usersInternal.getByNickname, {
58+ nickname: body.nickname
59+ });
46 60
47 return new Response(JSON.stringify(user), {61 return new Response(JSON.stringify(user), {
48 headers: { 'Content-Type': 'application/json' }62 headers: { 'Content-Type': 'application/json' }
@@ -57,9 +71,14 @@ http.route({
57 handler: httpAction(async (ctx, request) => {71 handler: httpAction(async (ctx, request) => {
58 if (!validateSecret(request)) return unauthorizedResponse();72 if (!validateSecret(request)) return unauthorizedResponse();
59 73
60- const { nickname, password } = await request.json();74+ const body = await parseJsonBody<{ nickname: string; password: string }>(request);
75+ if (!body) return errorResponse('Invalid JSON', 400);
76+
61 try {77 try {
62- const userId = await ctx.runMutation(internal.usersInternal.create, { nickname, password });78+ const userId = await ctx.runMutation(internal.usersInternal.create, {
79+ nickname: body.nickname,
80+ password: body.password
81+ });
63 return new Response(JSON.stringify({ userId }), {82 return new Response(JSON.stringify({ userId }), {
64 headers: { 'Content-Type': 'application/json' }83 headers: { 'Content-Type': 'application/json' }
65 });84 });
@@ -76,8 +95,10 @@ http.route({
76 handler: httpAction(async (ctx, request) => {95 handler: httpAction(async (ctx, request) => {
77 if (!validateSecret(request)) return unauthorizedResponse();96 if (!validateSecret(request)) return unauthorizedResponse();
78 97
79- const { id } = await request.json();98+ const body = await parseJsonBody<{ id: string }>(request);
80- const user = await ctx.runQuery(internal.usersInternal.getById, { id });99+ if (!body) return errorResponse('Invalid JSON', 400);
100+
101+ const user = await ctx.runQuery(internal.usersInternal.getById, { id: body.id as Id<'users'> });
81 102
82 return new Response(JSON.stringify(user), {103 return new Response(JSON.stringify(user), {
83 headers: { 'Content-Type': 'application/json' }104 headers: { 'Content-Type': 'application/json' }
@@ -107,11 +128,13 @@ http.route({
107 handler: httpAction(async (ctx, request) => {128 handler: httpAction(async (ctx, request) => {
108 if (!validateSecret(request)) return unauthorizedResponse();129 if (!validateSecret(request)) return unauthorizedResponse();
109 130
110- const { userId, status } = await request.json();131+ const body = await parseJsonBody<{ userId: string; status: string }>(request);
132+ if (!body) return errorResponse('Invalid JSON', 400);
133+
111 try {134 try {
112 const user = await ctx.runMutation(internal.usersInternal.updateStatus, {135 const user = await ctx.runMutation(internal.usersInternal.updateStatus, {
113- id: userId,136+ id: body.userId as Id<'users'>,
114- status137+ status: body.status
115 });138 });
116 return new Response(JSON.stringify(user), {139 return new Response(JSON.stringify(user), {
117 headers: { 'Content-Type': 'application/json' }140 headers: { 'Content-Type': 'application/json' }
@@ -146,12 +169,16 @@ http.route({
146 handler: httpAction(async (ctx, request) => {169 handler: httpAction(async (ctx, request) => {
147 if (!validateSecret(request)) return unauthorizedResponse();170 if (!validateSecret(request)) return unauthorizedResponse();
148 171
149- const { tokenHash, userId, expiresAt } = await request.json();172+ const body = await parseJsonBody<{ tokenHash: string; userId: string; expiresAt: number }>(
173+ request
174+ );
175+ if (!body) return errorResponse('Invalid JSON', 400);
176+
150 try {177 try {
151 const result = await ctx.runMutation(internal.sessionsInternal.create, {178 const result = await ctx.runMutation(internal.sessionsInternal.create, {
152- tokenHash,179+ tokenHash: body.tokenHash,
153- userId,180+ userId: body.userId as Id<'users'>,
154- expiresAt181+ expiresAt: body.expiresAt
155 });182 });
156 return new Response(JSON.stringify(result), {183 return new Response(JSON.stringify(result), {
157 headers: { 'Content-Type': 'application/json' }184 headers: { 'Content-Type': 'application/json' }
@@ -169,8 +196,12 @@ http.route({
169 handler: httpAction(async (ctx, request) => {196 handler: httpAction(async (ctx, request) => {
170 if (!validateSecret(request)) return unauthorizedResponse();197 if (!validateSecret(request)) return unauthorizedResponse();
171 198
172- const { tokenHash } = await request.json();199+ const body = await parseJsonBody<{ tokenHash: string }>(request);
173- const result = await ctx.runQuery(internal.sessionsInternal.validateAndGetUser, { tokenHash });200+ if (!body) return errorResponse('Invalid JSON', 400);
201+
202+ const result = await ctx.runQuery(internal.sessionsInternal.validateAndGetUser, {
203+ tokenHash: body.tokenHash
204+ });
174 205
175 return new Response(JSON.stringify(result), {206 return new Response(JSON.stringify(result), {
176 headers: { 'Content-Type': 'application/json' }207 headers: { 'Content-Type': 'application/json' }
@@ -185,8 +216,10 @@ http.route({
185 handler: httpAction(async (ctx, request) => {216 handler: httpAction(async (ctx, request) => {
186 if (!validateSecret(request)) return unauthorizedResponse();217 if (!validateSecret(request)) return unauthorizedResponse();
187 218
188- const { tokenHash } = await request.json();219+ const body = await parseJsonBody<{ tokenHash: string }>(request);
189- await ctx.runMutation(internal.sessionsInternal.remove, { tokenHash });220+ if (!body) return errorResponse('Invalid JSON', 400);
221+
222+ await ctx.runMutation(internal.sessionsInternal.remove, { tokenHash: body.tokenHash });
190 223
191 return new Response(JSON.stringify({ success: true }), {224 return new Response(JSON.stringify({ success: true }), {
192 headers: { 'Content-Type': 'application/json' }225 headers: { 'Content-Type': 'application/json' }
@@ -218,8 +251,12 @@ http.route({
218 handler: httpAction(async (ctx, request) => {251 handler: httpAction(async (ctx, request) => {
219 if (!validateSecret(request)) return unauthorizedResponse();252 if (!validateSecret(request)) return unauthorizedResponse();
220 253
221- const { id } = await request.json();254+ const body = await parseJsonBody<{ id: string }>(request);
222- const room = await ctx.runQuery(internal.chatRoomsInternal.getById, { id });255+ if (!body) return errorResponse('Invalid JSON', 400);
256+
257+ const room = await ctx.runQuery(internal.chatRoomsInternal.getById, {
258+ id: body.id as Id<'chatRooms'>
259+ });
223 260
224 return new Response(JSON.stringify(room), {261 return new Response(JSON.stringify(room), {
225 headers: { 'Content-Type': 'application/json' }262 headers: { 'Content-Type': 'application/json' }
@@ -236,11 +273,15 @@ http.route({
236 handler: httpAction(async (ctx, request) => {273 handler: httpAction(async (ctx, request) => {
237 if (!validateSecret(request)) return unauthorizedResponse();274 if (!validateSecret(request)) return unauthorizedResponse();
238 275
239- const { roomId, limit, beforeTimestamp } = await request.json();276+ const body = await parseJsonBody<{ roomId: string; limit?: number; beforeTimestamp?: number }>(
277+ request
278+ );
279+ if (!body) return errorResponse('Invalid JSON', 400);
280+
240 const result = await ctx.runQuery(internal.messagesInternal.getByRoom, {281 const result = await ctx.runQuery(internal.messagesInternal.getByRoom, {
241- roomId,282+ roomId: body.roomId as Id<'chatRooms'>,
242- limit,283+ limit: body.limit,
243- beforeTimestamp284+ beforeTimestamp: body.beforeTimestamp
244 });285 });
245 286
246 return new Response(JSON.stringify(result), {287 return new Response(JSON.stringify(result), {
@@ -256,17 +297,26 @@ http.route({
256 handler: httpAction(async (ctx, request) => {297 handler: httpAction(async (ctx, request) => {
257 if (!validateSecret(request)) return unauthorizedResponse();298 if (!validateSecret(request)) return unauthorizedResponse();
258 299
259- const { chatRoomId, senderId, content, type, styleData, hasFormatting, timestamp } =300+ const body = await parseJsonBody<{
260- await request.json();301+ chatRoomId: string;
302+ senderId: string;
303+ content: string;
304+ type: string;
305+ styleData?: string;
306+ hasFormatting?: boolean;
307+ timestamp?: number;
308+ }>(request);
309+ if (!body) return errorResponse('Invalid JSON', 400);
310+
261 try {311 try {
262 const message = await ctx.runMutation(internal.messagesInternal.send, {312 const message = await ctx.runMutation(internal.messagesInternal.send, {
263- chatRoomId,313+ chatRoomId: body.chatRoomId as Id<'chatRooms'>,
264- senderId,314+ senderId: body.senderId as Id<'users'>,
265- content,315+ content: body.content,
266- type,316+ type: body.type,
267- styleData,317+ styleData: body.styleData,
268- hasFormatting,318+ hasFormatting: body.hasFormatting,
269- timestamp319+ timestamp: body.timestamp
270 });320 });
271 return new Response(JSON.stringify(message), {321 return new Response(JSON.stringify(message), {
272 headers: { 'Content-Type': 'application/json' }322 headers: { 'Content-Type': 'application/json' }
@@ -286,9 +336,11 @@ http.route({
286 handler: httpAction(async (ctx, request) => {336 handler: httpAction(async (ctx, request) => {
287 if (!validateSecret(request)) return unauthorizedResponse();337 if (!validateSecret(request)) return unauthorizedResponse();
288 338
289- const { userId } = await request.json();339+ const body = await parseJsonBody<{ userId: string }>(request);
340+ if (!body) return errorResponse('Invalid JSON', 400);
341+
290 const preferences = await ctx.runQuery(internal.textPreferencesInternal.getByUserId, {342 const preferences = await ctx.runQuery(internal.textPreferencesInternal.getByUserId, {
291- userId343+ userId: body.userId as Id<'users'>
292 });344 });
293 345
294 return new Response(JSON.stringify(preferences), {346 return new Response(JSON.stringify(preferences), {
@@ -304,13 +356,29 @@ http.route({
304 handler: httpAction(async (ctx, request) => {356 handler: httpAction(async (ctx, request) => {
305 if (!validateSecret(request)) return unauthorizedResponse();357 if (!validateSecret(request)) return unauthorizedResponse();
306 358
307- const { userId, defaultStyle, allowFormatting, maxMessageLength } = await request.json();359+ const body = await parseJsonBody<{
360+ userId: string;
361+ defaultStyle: {
362+ fontFamily: string;
363+ fontSize: number;
364+ color?: string;
365+ gradient?: string[];
366+ bold: boolean;
367+ italic: boolean;
368+ underline: boolean;
369+ strikethrough: boolean;
370+ };
371+ allowFormatting: boolean;
372+ maxMessageLength: number;
373+ }>(request);
374+ if (!body) return errorResponse('Invalid JSON', 400);
375+
308 try {376 try {
309 const result = await ctx.runMutation(internal.textPreferencesInternal.upsert, {377 const result = await ctx.runMutation(internal.textPreferencesInternal.upsert, {
310- userId,378+ userId: body.userId as Id<'users'>,
311- defaultStyle,379+ defaultStyle: body.defaultStyle,
312- allowFormatting,380+ allowFormatting: body.allowFormatting,
313- maxMessageLength381+ maxMessageLength: body.maxMessageLength
314 });382 });
315 return new Response(JSON.stringify({ success: true, id: result }), {383 return new Response(JSON.stringify({ success: true, id: result }), {
316 headers: { 'Content-Type': 'application/json' }384 headers: { 'Content-Type': 'application/json' }
M src/hooks.server.ts +23 -0
@@ -49,6 +49,29 @@ Promise.resolve()
4949 export const handle: Handle = async ({ event, resolve }) => {
5050 log.debug('Handling request', { path: event.url.pathname });
5151
52+ // CSRF protection: validate Origin header for state-changing requests
53+ if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
54+ const origin = event.request.headers.get('origin');
55+ const host = event.request.headers.get('host');
56+
57+ // Block cross-origin requests (origin header is present but doesn't match host)
58+ if (origin) {
59+ try {
60+ const originUrl = new URL(origin);
61+ if (originUrl.host !== host) {
62+ log.warn('CSRF check failed - origin mismatch', {
63+ origin: originUrl.host,
64+ host
65+ });
66+ return new Response('Forbidden', { status: 403 });
67+ }
68+ } catch {
69+ log.warn('CSRF check failed - invalid origin', { origin });
70+ return new Response('Forbidden', { status: 403 });
71+ }
72+ }
73+ }
74+
5275 // Get session token from cookies
5376 const token = event.cookies.get('session') ?? null;
5477
@@ -49,6 +49,29 @@ Promise.resolve()
49 export const handle: Handle = async ({ event, resolve }) => {49 export const handle: Handle = async ({ event, resolve }) => {
50 log.debug('Handling request', { path: event.url.pathname });50 log.debug('Handling request', { path: event.url.pathname });
51 51
52+ // CSRF protection: validate Origin header for state-changing requests
53+ if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
54+ const origin = event.request.headers.get('origin');
55+ const host = event.request.headers.get('host');
56+
57+ // Block cross-origin requests (origin header is present but doesn't match host)
58+ if (origin) {
59+ try {
60+ const originUrl = new URL(origin);
61+ if (originUrl.host !== host) {
62+ log.warn('CSRF check failed - origin mismatch', {
63+ origin: originUrl.host,
64+ host
65+ });
66+ return new Response('Forbidden', { status: 403 });
67+ }
68+ } catch {
69+ log.warn('CSRF check failed - invalid origin', { origin });
70+ return new Response('Forbidden', { status: 403 });
71+ }
72+ }
73+ }
74+
52 // Get session token from cookies75 // Get session token from cookies
53 const token = event.cookies.get('session') ?? null;76 const token = event.cookies.get('session') ?? null;
54 77
M src/lib/api/session.server.ts +5 -7
@@ -1,5 +1,5 @@
11 import { sessions } from '../db/convex.server';
2-import type { Session, User } from '../types/chat';
2+import type { Session, SafeUser } from '../types/chat';
33 import { createLogger } from '../utils/logger.server';
44
55 const log = createLogger('session-server');
@@ -41,7 +41,7 @@ export function generateSessionToken(): string {
4141 const randomBytesArray = new Uint8Array(20);
4242 crypto.getRandomValues(randomBytesArray);
4343 const token = base32Encode(randomBytesArray);
44- log.debug('Generated session token', { token });
44+ log.debug('Generated session token', { tokenPrefix: token.slice(0, 4) + '***' });
4545 return token;
4646 }
4747
@@ -64,7 +64,7 @@ export async function createSession(token: string, userId: string): Promise<Sess
6464 * Returns an object with both session and user if valid, or nulls if not.
6565 */
6666 export type SessionValidationResult =
67- | { session: Session; user: User }
67+ | { session: Session; user: SafeUser }
6868 | { session: null; user: null };
6969
7070 // Validate a session token by converting it to its SHA-256 hash, checking expiration, and fetching the user.
@@ -87,13 +87,11 @@ export async function validateSessionToken(token: string): Promise<SessionValida
8787 expiresAt: result.session.expiresAt
8888 };
8989
90- const user: User = {
90+ const user: SafeUser = {
9191 id: result.user._id,
92- password: result.user.password,
9392 nickname: result.user.nickname,
94- status: result.user.status as User['status'],
93+ status: result.user.status as SafeUser['status'],
9594 avatarUrl: result.user.avatarUrl,
96- createdAt: result.user.createdAt,
9795 lastSeen: result.user.lastSeen
9896 };
9997
@@ -1,5 +1,5 @@
1 import { sessions } from '../db/convex.server';1 import { sessions } from '../db/convex.server';
2-import type { Session, User } from '../types/chat';2+import type { Session, SafeUser } from '../types/chat';
3 import { createLogger } from '../utils/logger.server';3 import { createLogger } from '../utils/logger.server';
4 4
5 const log = createLogger('session-server');5 const log = createLogger('session-server');
@@ -41,7 +41,7 @@ export function generateSessionToken(): string {
41 const randomBytesArray = new Uint8Array(20);41 const randomBytesArray = new Uint8Array(20);
42 crypto.getRandomValues(randomBytesArray);42 crypto.getRandomValues(randomBytesArray);
43 const token = base32Encode(randomBytesArray);43 const token = base32Encode(randomBytesArray);
44- log.debug('Generated session token', { token });44+ log.debug('Generated session token', { tokenPrefix: token.slice(0, 4) + '***' });
45 return token;45 return token;
46 }46 }
47 47
@@ -64,7 +64,7 @@ export async function createSession(token: string, userId: string): Promise<Sess
64 * Returns an object with both session and user if valid, or nulls if not.64 * Returns an object with both session and user if valid, or nulls if not.
65 */65 */
66 export type SessionValidationResult =66 export type SessionValidationResult =
67- | { session: Session; user: User }67+ | { session: Session; user: SafeUser }
68 | { session: null; user: null };68 | { session: null; user: null };
69 69
70 // Validate a session token by converting it to its SHA-256 hash, checking expiration, and fetching the user.70 // Validate a session token by converting it to its SHA-256 hash, checking expiration, and fetching the user.
@@ -87,13 +87,11 @@ export async function validateSessionToken(token: string): Promise<SessionValida
87 expiresAt: result.session.expiresAt87 expiresAt: result.session.expiresAt
88 };88 };
89 89
90- const user: User = {90+ const user: SafeUser = {
91 id: result.user._id,91 id: result.user._id,
92- password: result.user.password,
93 nickname: result.user.nickname,92 nickname: result.user.nickname,
94- status: result.user.status as User['status'],93+ status: result.user.status as SafeUser['status'],
95 avatarUrl: result.user.avatarUrl,94 avatarUrl: result.user.avatarUrl,
96- createdAt: result.user.createdAt,
97 lastSeen: result.user.lastSeen95 lastSeen: result.user.lastSeen
98 };96 };
99 97
M src/lib/utils/password.ts +16 -1
@@ -23,6 +23,21 @@ function hexToBuffer(hex: string): Uint8Array {
2323 return arr;
2424 }
2525
26+/**
27+ * Performs a constant-time string comparison to prevent timing attacks.
28+ * Returns true if strings are equal, false otherwise.
29+ */
30+function constantTimeCompare(a: string, b: string): boolean {
31+ if (a.length !== b.length) {
32+ return false;
33+ }
34+ let result = 0;
35+ for (let i = 0; i < a.length; i++) {
36+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
37+ }
38+ return result === 0;
39+}
40+
2641 /**
2742 * Derives a password hash using PBKDF2 with the provided salt.
2843 * @param password The plain text password.
@@ -70,5 +85,5 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
7085 const [saltHex, hashValue] = hash.split(':');
7186 const salt = hexToBuffer(saltHex);
7287 const derivedHash = await derivePasswordHash(password, salt);
73- return derivedHash === hashValue;
88+ return constantTimeCompare(derivedHash, hashValue);
7489 }
@@ -23,6 +23,21 @@ function hexToBuffer(hex: string): Uint8Array {
23 return arr;23 return arr;
24 }24 }
25 25
26+/**
27+ * Performs a constant-time string comparison to prevent timing attacks.
28+ * Returns true if strings are equal, false otherwise.
29+ */
30+function constantTimeCompare(a: string, b: string): boolean {
31+ if (a.length !== b.length) {
32+ return false;
33+ }
34+ let result = 0;
35+ for (let i = 0; i < a.length; i++) {
36+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
37+ }
38+ return result === 0;
39+}
40+
26 /**41 /**
27 * Derives a password hash using PBKDF2 with the provided salt.42 * Derives a password hash using PBKDF2 with the provided salt.
28 * @param password The plain text password.43 * @param password The plain text password.
@@ -70,5 +85,5 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
70 const [saltHex, hashValue] = hash.split(':');85 const [saltHex, hashValue] = hash.split(':');
71 const salt = hexToBuffer(saltHex);86 const salt = hexToBuffer(saltHex);
72 const derivedHash = await derivePasswordHash(password, salt);87 const derivedHash = await derivePasswordHash(password, salt);
73- return derivedHash === hashValue;88+ return constantTimeCompare(derivedHash, hashValue);
74 }89 }
M src/routes/api/session/login/+server.ts +4 -1
@@ -41,7 +41,10 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
4141 }
4242
4343 // Get the IP address for rate limiting and Turnstile validation
44- const ip = request.headers.get('x-forwarded-for') || 'unknown';
44+ // Prefer Cloudflare's CF-Connecting-IP header, then x-forwarded-for (first IP), then fallback
45+ const cfConnectingIp = request.headers.get('cf-connecting-ip');
46+ const forwardedFor = request.headers.get('x-forwarded-for') || '';
47+ const ip = cfConnectingIp || forwardedFor.split(',')[0]?.trim() || 'unknown';
4548 const maskedIp = ip
4649 .split('.')
4750 .map((octet, idx) => (idx < 3 ? 'xxx' : octet))
@@ -41,7 +41,10 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
41 }41 }
42 42
43 // Get the IP address for rate limiting and Turnstile validation43 // Get the IP address for rate limiting and Turnstile validation
44- const ip = request.headers.get('x-forwarded-for') || 'unknown';44+ // Prefer Cloudflare's CF-Connecting-IP header, then x-forwarded-for (first IP), then fallback
45+ const cfConnectingIp = request.headers.get('cf-connecting-ip');
46+ const forwardedFor = request.headers.get('x-forwarded-for') || '';
47+ const ip = cfConnectingIp || forwardedFor.split(',')[0]?.trim() || 'unknown';
45 const maskedIp = ip48 const maskedIp = ip
46 .split('.')49 .split('.')
47 .map((octet, idx) => (idx < 3 ? 'xxx' : octet))50 .map((octet, idx) => (idx < 3 ? 'xxx' : octet))
M src/routes/api/session/logout/+server.ts +14 -5
@@ -9,16 +9,25 @@ const log = createLogger('logout-server');
99 export const POST: RequestHandler = async ({ cookies }) => {
1010 const token = cookies.get('session');
1111
12- if (token) {
12+ if (!token) {
13+ log.debug('No session token found in cookies');
14+ return json({ success: true, invalidated: false });
15+ }
16+
17+ try {
1318 const sessionId = await sha256(token);
1419 await invalidateSession(sessionId);
1520 cookies.delete('session', { path: '/' });
1621 log.info('Session invalidated', {
1722 sessionId: `${sessionId.slice(0, 4)}...${sessionId.slice(-4)}`
1823 });
19- } else {
20- log.debug('No session token found in cookies');
24+ return json({ success: true, invalidated: true });
25+ } catch (error) {
26+ log.error('Failed to invalidate session', {
27+ error: error instanceof Error ? error.message : 'Unknown error'
28+ });
29+ // Still delete cookie to ensure client is logged out
30+ cookies.delete('session', { path: '/' });
31+ return json({ success: false, error: 'Failed to invalidate session' }, { status: 500 });
2132 }
22-
23- return json({ success: true });
2433 };
@@ -9,16 +9,25 @@ const log = createLogger('logout-server');
9 export const POST: RequestHandler = async ({ cookies }) => {9 export const POST: RequestHandler = async ({ cookies }) => {
10 const token = cookies.get('session');10 const token = cookies.get('session');
11 11
12- if (token) {12+ if (!token) {
13+ log.debug('No session token found in cookies');
14+ return json({ success: true, invalidated: false });
15+ }
16+
17+ try {
13 const sessionId = await sha256(token);18 const sessionId = await sha256(token);
14 await invalidateSession(sessionId);19 await invalidateSession(sessionId);
15 cookies.delete('session', { path: '/' });20 cookies.delete('session', { path: '/' });
16 log.info('Session invalidated', {21 log.info('Session invalidated', {
17 sessionId: `${sessionId.slice(0, 4)}...${sessionId.slice(-4)}`22 sessionId: `${sessionId.slice(0, 4)}...${sessionId.slice(-4)}`
18 });23 });
19- } else {24+ return json({ success: true, invalidated: true });
20- log.debug('No session token found in cookies');25+ } catch (error) {
26+ log.error('Failed to invalidate session', {
27+ error: error instanceof Error ? error.message : 'Unknown error'
28+ });
29+ // Still delete cookie to ensure client is logged out
30+ cookies.delete('session', { path: '/' });
31+ return json({ success: false, error: 'Failed to invalidate session' }, { status: 500 });
21 }32 }
22-
23- return json({ success: true });
24 };33 };
M src/routes/api/session/validate/+server.ts +3 -3
@@ -20,9 +20,9 @@ export const GET: RequestHandler = async ({ cookies }) => {
2020 userId: `${result.user.id.slice(0, 4)}...${result.user.id.slice(-4)}`,
2121 sessionId: `${result.session.id.slice(0, 4)}...${result.session.id.slice(-4)}`
2222 });
23- } else {
24- log.debug('Invalid session token');
23+ return json(result, { status: 200 });
2524 }
2625
27- return json(result);
26+ log.debug('Invalid session token');
27+ return json({ session: null, user: null }, { status: 401 });
2828 };
@@ -20,9 +20,9 @@ export const GET: RequestHandler = async ({ cookies }) => {
20 userId: `${result.user.id.slice(0, 4)}...${result.user.id.slice(-4)}`,20 userId: `${result.user.id.slice(0, 4)}...${result.user.id.slice(-4)}`,
21 sessionId: `${result.session.id.slice(0, 4)}...${result.session.id.slice(-4)}`21 sessionId: `${result.session.id.slice(0, 4)}...${result.session.id.slice(-4)}`
22 });22 });
23- } else {23+ return json(result, { status: 200 });
24- log.debug('Invalid session token');
25 }24 }
26 25
27- return json(result);26+ log.debug('Invalid session token');
27+ return json({ session: null, user: null }, { status: 401 });
28 };28 };
M src/routes/api/status/+server.ts +25 -16
@@ -39,22 +39,30 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
3939
4040 // Renew session if status is online and session expires in less than 1 day
4141 if (status === 'online') {
42- const token = cookies.get('session');
43- if (token) {
44- const result = await validateSessionToken(token);
45- if (result.session) {
46- const remaining = result.session.expiresAt - now;
47- const threshold = 24 * 60 * 60 * 1000; // 1 day in milliseconds
48- if (remaining < threshold) {
49- const newToken = generateSessionToken();
50- const newSession = await createSession(newToken, userId);
51- setSessionTokenCookie({ cookies }, newToken, newSession.expiresAt);
52- log.info('Session renewed', {
53- userId: maskedUserId,
54- expiresAt: new Date(newSession.expiresAt).toISOString()
55- });
42+ try {
43+ const token = cookies.get('session');
44+ if (token) {
45+ const result = await validateSessionToken(token);
46+ if (result.session) {
47+ const remaining = result.session.expiresAt - now;
48+ const threshold = 24 * 60 * 60 * 1000; // 1 day in milliseconds
49+ if (remaining < threshold) {
50+ const newToken = generateSessionToken();
51+ const newSession = await createSession(newToken, userId);
52+ setSessionTokenCookie({ cookies }, newToken, newSession.expiresAt);
53+ log.info('Session renewed', {
54+ userId: maskedUserId,
55+ expiresAt: new Date(newSession.expiresAt).toISOString()
56+ });
57+ }
5658 }
5759 }
60+ } catch (renewalError) {
61+ log.error('Session renewal failed', {
62+ error: renewalError instanceof Error ? renewalError.message : 'Unknown error',
63+ userId: maskedUserId
64+ });
65+ // Continue with status update even if renewal fails
5866 }
5967 }
6068
@@ -76,12 +84,13 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
7684 );
7785 } catch (error) {
7886 log.error('Error updating status', {
79- error: error instanceof Error ? error.message : 'Unknown error'
87+ error: error instanceof Error ? error.message : 'Unknown error',
88+ userId: locals.user?.id
8089 });
8190 return new Response(
8291 JSON.stringify({
8392 success: false,
84- error: error instanceof Error ? error.message : 'Unknown error'
93+ error: 'Failed to update status'
8594 }),
8695 {
8796 status: 500,
@@ -39,22 +39,30 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
39 39
40 // Renew session if status is online and session expires in less than 1 day40 // Renew session if status is online and session expires in less than 1 day
41 if (status === 'online') {41 if (status === 'online') {
42- const token = cookies.get('session');42+ try {
43- if (token) {43+ const token = cookies.get('session');
44- const result = await validateSessionToken(token);44+ if (token) {
45- if (result.session) {45+ const result = await validateSessionToken(token);
46- const remaining = result.session.expiresAt - now;46+ if (result.session) {
47- const threshold = 24 * 60 * 60 * 1000; // 1 day in milliseconds47+ const remaining = result.session.expiresAt - now;
48- if (remaining < threshold) {48+ const threshold = 24 * 60 * 60 * 1000; // 1 day in milliseconds
49- const newToken = generateSessionToken();49+ if (remaining < threshold) {
50- const newSession = await createSession(newToken, userId);50+ const newToken = generateSessionToken();
51- setSessionTokenCookie({ cookies }, newToken, newSession.expiresAt);51+ const newSession = await createSession(newToken, userId);
52- log.info('Session renewed', {52+ setSessionTokenCookie({ cookies }, newToken, newSession.expiresAt);
53- userId: maskedUserId,53+ log.info('Session renewed', {
54- expiresAt: new Date(newSession.expiresAt).toISOString()54+ userId: maskedUserId,
55- });55+ expiresAt: new Date(newSession.expiresAt).toISOString()
56+ });
57+ }
56 }58 }
57 }59 }
60+ } catch (renewalError) {
61+ log.error('Session renewal failed', {
62+ error: renewalError instanceof Error ? renewalError.message : 'Unknown error',
63+ userId: maskedUserId
64+ });
65+ // Continue with status update even if renewal fails
58 }66 }
59 }67 }
60 68
@@ -76,12 +84,13 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
76 );84 );
77 } catch (error) {85 } catch (error) {
78 log.error('Error updating status', {86 log.error('Error updating status', {
79- error: error instanceof Error ? error.message : 'Unknown error'87+ error: error instanceof Error ? error.message : 'Unknown error',
88+ userId: locals.user?.id
80 });89 });
81 return new Response(90 return new Response(
82 JSON.stringify({91 JSON.stringify({
83 success: false,92 success: false,
84- error: error instanceof Error ? error.message : 'Unknown error'93+ error: 'Failed to update status'
85 }),94 }),
86 {95 {
87 status: 500,96 status: 500,

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