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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +199 -74
M src/app.d.ts +2 -2
| @@ -1,12 +1,12 @@ | |||
| 1 | 1 | // See https://svelte.dev/docs/kit/types#app.d.ts | |
| 2 | 2 | // 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 | 5 | declare global { | |
| 6 | 6 | namespace App { | |
| 7 | 7 | // interface Error {} | |
| 8 | 8 | interface Locals { | |
| 9 | - | user: User | null; | |
| 9 | + | user: SafeUser | null; | |
| 10 | 10 | session: Session | null; | |
| 11 | 11 | } | |
| 12 | 12 | // interface PageData {} | |
| @@ -1,12 +1,12 @@ | |||||
| 1 | // See https://svelte.dev/docs/kit/types#app.d.ts | 1 | // See https://svelte.dev/docs/kit/types#app.d.ts | ||
| 2 | // for information about these interfaces | 2 | // 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 @@ | |||
| 1 | 1 | import { httpRouter } from 'convex/server'; | |
| 2 | 2 | import { httpAction } from './_generated/server'; | |
| 3 | 3 | import { internal } from './_generated/api'; | |
| 4 | + | import type { Id } from './_generated/dataModel'; | |
| 4 | 5 | ||
| 5 | 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 | 45 | // ============ USERS (server-side only) ============ | |
| 36 | 46 | ||
| 37 | 47 | // Get user by nickname - for login | |
| @@ -41,8 +51,12 @@ http.route({ | |||
| 41 | 51 | handler: httpAction(async (ctx, request) => { | |
| 42 | 52 | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 43 | 53 | ||
| 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 | + | }); | |
| 46 | 60 | ||
| 47 | 61 | return new Response(JSON.stringify(user), { | |
| 48 | 62 | headers: { 'Content-Type': 'application/json' } | |
| @@ -57,9 +71,14 @@ http.route({ | |||
| 57 | 71 | handler: httpAction(async (ctx, request) => { | |
| 58 | 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 | 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 | 82 | return new Response(JSON.stringify({ userId }), { | |
| 64 | 83 | headers: { 'Content-Type': 'application/json' } | |
| 65 | 84 | }); | |
| @@ -76,8 +95,10 @@ http.route({ | |||
| 76 | 95 | handler: httpAction(async (ctx, request) => { | |
| 77 | 96 | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 78 | 97 | ||
| 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'> }); | |
| 81 | 102 | ||
| 82 | 103 | return new Response(JSON.stringify(user), { | |
| 83 | 104 | headers: { 'Content-Type': 'application/json' } | |
| @@ -107,11 +128,13 @@ http.route({ | |||
| 107 | 128 | handler: httpAction(async (ctx, request) => { | |
| 108 | 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 | 134 | try { | |
| 112 | 135 | const user = await ctx.runMutation(internal.usersInternal.updateStatus, { | |
| 113 | - | id: userId, | |
| 114 | - | status | |
| 136 | + | id: body.userId as Id<'users'>, | |
| 137 | + | status: body.status | |
| 115 | 138 | }); | |
| 116 | 139 | return new Response(JSON.stringify(user), { | |
| 117 | 140 | headers: { 'Content-Type': 'application/json' } | |
| @@ -146,12 +169,16 @@ http.route({ | |||
| 146 | 169 | handler: httpAction(async (ctx, request) => { | |
| 147 | 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 | 177 | try { | |
| 151 | 178 | 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 | |
| 155 | 182 | }); | |
| 156 | 183 | return new Response(JSON.stringify(result), { | |
| 157 | 184 | headers: { 'Content-Type': 'application/json' } | |
| @@ -169,8 +196,12 @@ http.route({ | |||
| 169 | 196 | handler: httpAction(async (ctx, request) => { | |
| 170 | 197 | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 171 | 198 | ||
| 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 | + | }); | |
| 174 | 205 | ||
| 175 | 206 | return new Response(JSON.stringify(result), { | |
| 176 | 207 | headers: { 'Content-Type': 'application/json' } | |
| @@ -185,8 +216,10 @@ http.route({ | |||
| 185 | 216 | handler: httpAction(async (ctx, request) => { | |
| 186 | 217 | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 187 | 218 | ||
| 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 }); | |
| 190 | 223 | ||
| 191 | 224 | return new Response(JSON.stringify({ success: true }), { | |
| 192 | 225 | headers: { 'Content-Type': 'application/json' } | |
| @@ -218,8 +251,12 @@ http.route({ | |||
| 218 | 251 | handler: httpAction(async (ctx, request) => { | |
| 219 | 252 | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 220 | 253 | ||
| 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 | + | }); | |
| 223 | 260 | ||
| 224 | 261 | return new Response(JSON.stringify(room), { | |
| 225 | 262 | headers: { 'Content-Type': 'application/json' } | |
| @@ -236,11 +273,15 @@ http.route({ | |||
| 236 | 273 | handler: httpAction(async (ctx, request) => { | |
| 237 | 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 | 281 | 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 | |
| 244 | 285 | }); | |
| 245 | 286 | ||
| 246 | 287 | return new Response(JSON.stringify(result), { | |
| @@ -256,17 +297,26 @@ http.route({ | |||
| 256 | 297 | handler: httpAction(async (ctx, request) => { | |
| 257 | 298 | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 258 | 299 | ||
| 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 | + | ||
| 261 | 311 | try { | |
| 262 | 312 | 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 | |
| 270 | 320 | }); | |
| 271 | 321 | return new Response(JSON.stringify(message), { | |
| 272 | 322 | headers: { 'Content-Type': 'application/json' } | |
| @@ -286,9 +336,11 @@ http.route({ | |||
| 286 | 336 | handler: httpAction(async (ctx, request) => { | |
| 287 | 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 | 342 | const preferences = await ctx.runQuery(internal.textPreferencesInternal.getByUserId, { | |
| 291 | - | userId | |
| 343 | + | userId: body.userId as Id<'users'> | |
| 292 | 344 | }); | |
| 293 | 345 | ||
| 294 | 346 | return new Response(JSON.stringify(preferences), { | |
| @@ -304,13 +356,29 @@ http.route({ | |||
| 304 | 356 | handler: httpAction(async (ctx, request) => { | |
| 305 | 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 | 376 | try { | |
| 309 | 377 | 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 | |
| 314 | 382 | }); | |
| 315 | 383 | return new Response(JSON.stringify({ success: true, id: result }), { | |
| 316 | 384 | 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 login | 47 | // 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 | - | status | 137 | + | 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 | - | expiresAt | 181 | + | 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 | - | beforeTimestamp | 284 | + | 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 | - | timestamp | 319 | + | 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 | - | userId | 343 | + | 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 | - | maxMessageLength | 381 | + | 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() | |||
| 49 | 49 | export const handle: Handle = async ({ event, resolve }) => { | |
| 50 | 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 | 75 | // Get session token from cookies | |
| 53 | 76 | const token = event.cookies.get('session') ?? null; | |
| 54 | 77 | ||
| @@ -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 cookies | 75 | // 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 @@ | |||
| 1 | 1 | import { sessions } from '../db/convex.server'; | |
| 2 | - | import type { Session, User } from '../types/chat'; | |
| 2 | + | import type { Session, SafeUser } from '../types/chat'; | |
| 3 | 3 | import { createLogger } from '../utils/logger.server'; | |
| 4 | 4 | ||
| 5 | 5 | const log = createLogger('session-server'); | |
| @@ -41,7 +41,7 @@ export function generateSessionToken(): string { | |||
| 41 | 41 | const randomBytesArray = new Uint8Array(20); | |
| 42 | 42 | crypto.getRandomValues(randomBytesArray); | |
| 43 | 43 | const token = base32Encode(randomBytesArray); | |
| 44 | - | log.debug('Generated session token', { token }); | |
| 44 | + | log.debug('Generated session token', { tokenPrefix: token.slice(0, 4) + '***' }); | |
| 45 | 45 | return token; | |
| 46 | 46 | } | |
| 47 | 47 | ||
| @@ -64,7 +64,7 @@ export async function createSession(token: string, userId: string): Promise<Sess | |||
| 64 | 64 | * Returns an object with both session and user if valid, or nulls if not. | |
| 65 | 65 | */ | |
| 66 | 66 | export type SessionValidationResult = | |
| 67 | - | | { session: Session; user: User } | |
| 67 | + | | { session: Session; user: SafeUser } | |
| 68 | 68 | | { session: null; user: null }; | |
| 69 | 69 | ||
| 70 | 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 | 87 | expiresAt: result.session.expiresAt | |
| 88 | 88 | }; | |
| 89 | 89 | ||
| 90 | - | const user: User = { | |
| 90 | + | const user: SafeUser = { | |
| 91 | 91 | id: result.user._id, | |
| 92 | - | password: result.user.password, | |
| 93 | 92 | nickname: result.user.nickname, | |
| 94 | - | status: result.user.status as User['status'], | |
| 93 | + | status: result.user.status as SafeUser['status'], | |
| 95 | 94 | avatarUrl: result.user.avatarUrl, | |
| 96 | - | createdAt: result.user.createdAt, | |
| 97 | 95 | lastSeen: result.user.lastSeen | |
| 98 | 96 | }; | |
| 99 | 97 | ||
| @@ -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.expiresAt | 87 | 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.lastSeen | 95 | 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 { | |||
| 23 | 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 | 42 | * Derives a password hash using PBKDF2 with the provided salt. | |
| 28 | 43 | * @param password The plain text password. | |
| @@ -70,5 +85,5 @@ export async function verifyPassword(password: string, hash: string): Promise<bo | |||
| 70 | 85 | const [saltHex, hashValue] = hash.split(':'); | |
| 71 | 86 | const salt = hexToBuffer(saltHex); | |
| 72 | 87 | const derivedHash = await derivePasswordHash(password, salt); | |
| 73 | - | return derivedHash === hashValue; | |
| 88 | + | return constantTimeCompare(derivedHash, hashValue); | |
| 74 | 89 | } | |
| @@ -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 }) => { | |||
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | 43 | // 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 | 48 | const maskedIp = ip | |
| 46 | 49 | .split('.') | |
| 47 | 50 | .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 validation | 43 | // 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 = ip | 48 | 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'); | |||
| 9 | 9 | export const POST: RequestHandler = async ({ cookies }) => { | |
| 10 | 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 | 18 | const sessionId = await sha256(token); | |
| 14 | 19 | await invalidateSession(sessionId); | |
| 15 | 20 | cookies.delete('session', { path: '/' }); | |
| 16 | 21 | log.info('Session invalidated', { | |
| 17 | 22 | sessionId: `${sessionId.slice(0, 4)}...${sessionId.slice(-4)}` | |
| 18 | 23 | }); | |
| 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 }); | |
| 21 | 32 | } | |
| 22 | - | ||
| 23 | - | return json({ success: true }); | |
| 24 | 33 | }; | |
| @@ -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 }) => { | |||
| 20 | 20 | userId: `${result.user.id.slice(0, 4)}...${result.user.id.slice(-4)}`, | |
| 21 | 21 | sessionId: `${result.session.id.slice(0, 4)}...${result.session.id.slice(-4)}` | |
| 22 | 22 | }); | |
| 23 | - | } else { | |
| 24 | - | log.debug('Invalid session token'); | |
| 23 | + | return json(result, { status: 200 }); | |
| 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 | }; | |
| @@ -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 }) => { | |||
| 39 | 39 | ||
| 40 | 40 | // Renew session if status is online and session expires in less than 1 day | |
| 41 | 41 | 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 | + | } | |
| 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 | 85 | } catch (error) { | |
| 78 | 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 | 90 | return new Response( | |
| 82 | 91 | JSON.stringify({ | |
| 83 | 92 | success: false, | |
| 84 | - | error: error instanceof Error ? error.message : 'Unknown error' | |
| 93 | + | error: 'Failed to update status' | |
| 85 | 94 | }), | |
| 86 | 95 | { | |
| 87 | 96 | 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 day | 40 | // 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 milliseconds | 47 | + | 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, | ||