refactor: Improve authentication and rate limiting for SSE and public routes
- Refactor server hooks to handle SSE and public routes more precisely - Remove SSE from public routes list and add specific SSE authentication checks - Implement conditional rate limiting based on user authentication - Enhance debug headers and logging for SSE and authentication flows
- commit
- d4613e47a391433ec9e690a6800174e7f0cc0ddc
- author
- Kit · · 2025-02-15 07:19 UTC
- parent
- 3a0a925
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
3 files · +75 -32
M src/hooks.server.ts +24 -18
| @@ -34,39 +34,30 @@ initializeDatabase() | |||
| 34 | 34 | export const handle: Handle = async ({ event, resolve }) => { | |
| 35 | 35 | console.debug("Handling request:", event.url.pathname); | |
| 36 | 36 | ||
| 37 | - | // Apply rate limiting for API routes | |
| 38 | - | const rateLimitResponse = await handleRateLimit(event); | |
| 39 | - | if (rateLimitResponse) { | |
| 40 | - | return rateLimitResponse; | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | // Check if this is a public chat messages request | |
| 37 | + | // Identify public chat and room requests | |
| 44 | 38 | const isPublicChatRequest = | |
| 45 | 39 | event.url.pathname === '/api/chat/messages' && | |
| 46 | 40 | event.url.searchParams.get('public') === 'true' && | |
| 47 | 41 | event.request.method === 'GET'; | |
| 48 | - | ||
| 49 | - | // Check if this is a public room request | |
| 50 | 42 | const isPublicRoomRequest = | |
| 51 | 43 | event.url.pathname.startsWith('/api/rooms/') && | |
| 52 | 44 | event.url.searchParams.get('public') === 'true' && | |
| 53 | 45 | event.request.method === 'GET'; | |
| 54 | 46 | ||
| 55 | - | // Public routes that don't require authentication | |
| 47 | + | // Public routes that don't require authentication (removed '/api/sse' here) | |
| 56 | 48 | const publicRoutes = [ | |
| 57 | 49 | "/", | |
| 58 | 50 | "/api/session/login", | |
| 59 | 51 | "/api/session/validate", | |
| 60 | 52 | "/api/register", | |
| 61 | 53 | "/login", | |
| 62 | - | "/register", | |
| 63 | - | "/api/sse" | |
| 54 | + | "/register" | |
| 64 | 55 | ]; | |
| 65 | 56 | ||
| 66 | 57 | // Get session token from cookies | |
| 67 | 58 | const token = event.cookies.get("session") ?? null; | |
| 68 | - | ||
| 69 | - | // Add debug headers for SSE requests | |
| 59 | + | ||
| 60 | + | // Function to add debug headers for SSE requests | |
| 70 | 61 | const addDebugHeaders = (response: Response) => { | |
| 71 | 62 | if (event.url.pathname.startsWith('/api/sse')) { | |
| 72 | 63 | response.headers.set('X-Debug-Has-Token', Boolean(token).toString()); | |
| @@ -76,7 +67,7 @@ export const handle: Handle = async ({ event, resolve }) => { | |||
| 76 | 67 | return response; | |
| 77 | 68 | }; | |
| 78 | 69 | ||
| 79 | - | // Handle public requests separately: always clear session | |
| 70 | + | // Handle public chat/room requests | |
| 80 | 71 | if (isPublicChatRequest || isPublicRoomRequest) { | |
| 81 | 72 | console.debug("Public request accessed:", event.url.pathname); | |
| 82 | 73 | event.locals.user = null; | |
| @@ -100,7 +91,7 @@ export const handle: Handle = async ({ event, resolve }) => { | |||
| 100 | 91 | const response = await resolve(event); | |
| 101 | 92 | return addDebugHeaders(response); | |
| 102 | 93 | } | |
| 103 | - | ||
| 94 | + | ||
| 104 | 95 | // Protected routes: ensure authentication | |
| 105 | 96 | if (token === null) { | |
| 106 | 97 | console.debug("No session token found"); | |
| @@ -116,8 +107,6 @@ export const handle: Handle = async ({ event, resolve }) => { | |||
| 116 | 107 | setSessionTokenCookie(event, token, session.expiresAt); | |
| 117 | 108 | event.locals.session = session; | |
| 118 | 109 | event.locals.user = user; | |
| 119 | - | const response = await resolve(event); | |
| 120 | - | return addDebugHeaders(response); | |
| 121 | 110 | } else { | |
| 122 | 111 | console.debug("Invalid session token"); | |
| 123 | 112 | deleteSessionTokenCookie(event); | |
| @@ -126,4 +115,21 @@ export const handle: Handle = async ({ event, resolve }) => { | |||
| 126 | 115 | const response = new Response("Unauthorized", { status: 401 }); | |
| 127 | 116 | return addDebugHeaders(response); | |
| 128 | 117 | } | |
| 118 | + | ||
| 119 | + | // After authentication, ensure SSE endpoint does not trigger rate limiting | |
| 120 | + | if (event.url.pathname.startsWith('/api/sse') && !event.locals.user) { | |
| 121 | + | console.debug("SSE endpoint accessed without valid session, returning 401 without rate limiting"); | |
| 122 | + | return addDebugHeaders(new Response("Unauthorized", { status: 401 })); | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // For any request without an authenticated user, apply rate limiting | |
| 126 | + | if (!event.locals.user) { | |
| 127 | + | const rateLimitResponse = await handleRateLimit(event); | |
| 128 | + | if (rateLimitResponse) { | |
| 129 | + | return rateLimitResponse; | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | const response = await resolve(event); | |
| 134 | + | return addDebugHeaders(response); | |
| 129 | 135 | }; ⏎̸ | |
| @@ -34,39 +34,30 @@ initializeDatabase() | |||||
| 34 | export const handle: Handle = async ({ event, resolve }) => { | 34 | export const handle: Handle = async ({ event, resolve }) => { | ||
| 35 | console.debug("Handling request:", event.url.pathname); | 35 | console.debug("Handling request:", event.url.pathname); | ||
| 36 | 36 | ||||
| 37 | - | // Apply rate limiting for API routes | 37 | + | // Identify public chat and room requests |
| 38 | - | const rateLimitResponse = await handleRateLimit(event); | |||
| 39 | - | if (rateLimitResponse) { | |||
| 40 | - | return rateLimitResponse; | |||
| 41 | - | } | |||
| 42 | - | ||||
| 43 | - | // Check if this is a public chat messages request | |||
| 44 | const isPublicChatRequest = | 38 | const isPublicChatRequest = | ||
| 45 | event.url.pathname === '/api/chat/messages' && | 39 | event.url.pathname === '/api/chat/messages' && | ||
| 46 | event.url.searchParams.get('public') === 'true' && | 40 | event.url.searchParams.get('public') === 'true' && | ||
| 47 | event.request.method === 'GET'; | 41 | event.request.method === 'GET'; | ||
| 48 | - | ||||
| 49 | - | // Check if this is a public room request | |||
| 50 | const isPublicRoomRequest = | 42 | const isPublicRoomRequest = | ||
| 51 | event.url.pathname.startsWith('/api/rooms/') && | 43 | event.url.pathname.startsWith('/api/rooms/') && | ||
| 52 | event.url.searchParams.get('public') === 'true' && | 44 | event.url.searchParams.get('public') === 'true' && | ||
| 53 | event.request.method === 'GET'; | 45 | event.request.method === 'GET'; | ||
| 54 | 46 | ||||
| 55 | - | // Public routes that don't require authentication | 47 | + | // Public routes that don't require authentication (removed '/api/sse' here) |
| 56 | const publicRoutes = [ | 48 | const publicRoutes = [ | ||
| 57 | "/", | 49 | "/", | ||
| 58 | "/api/session/login", | 50 | "/api/session/login", | ||
| 59 | "/api/session/validate", | 51 | "/api/session/validate", | ||
| 60 | "/api/register", | 52 | "/api/register", | ||
| 61 | "/login", | 53 | "/login", | ||
| 62 | - | "/register", | 54 | + | "/register" |
| 63 | - | "/api/sse" | |||
| 64 | ]; | 55 | ]; | ||
| 65 | 56 | ||||
| 66 | // Get session token from cookies | 57 | // Get session token from cookies | ||
| 67 | const token = event.cookies.get("session") ?? null; | 58 | const token = event.cookies.get("session") ?? null; | ||
| 68 | - | 59 | + | ||
| 69 | - | // Add debug headers for SSE requests | 60 | + | // Function to add debug headers for SSE requests |
| 70 | const addDebugHeaders = (response: Response) => { | 61 | const addDebugHeaders = (response: Response) => { | ||
| 71 | if (event.url.pathname.startsWith('/api/sse')) { | 62 | if (event.url.pathname.startsWith('/api/sse')) { | ||
| 72 | response.headers.set('X-Debug-Has-Token', Boolean(token).toString()); | 63 | response.headers.set('X-Debug-Has-Token', Boolean(token).toString()); | ||
| @@ -76,7 +67,7 @@ export const handle: Handle = async ({ event, resolve }) => { | |||||
| 76 | return response; | 67 | return response; | ||
| 77 | }; | 68 | }; | ||
| 78 | 69 | ||||
| 79 | - | // Handle public requests separately: always clear session | 70 | + | // Handle public chat/room requests |
| 80 | if (isPublicChatRequest || isPublicRoomRequest) { | 71 | if (isPublicChatRequest || isPublicRoomRequest) { | ||
| 81 | console.debug("Public request accessed:", event.url.pathname); | 72 | console.debug("Public request accessed:", event.url.pathname); | ||
| 82 | event.locals.user = null; | 73 | event.locals.user = null; | ||
| @@ -100,7 +91,7 @@ export const handle: Handle = async ({ event, resolve }) => { | |||||
| 100 | const response = await resolve(event); | 91 | const response = await resolve(event); | ||
| 101 | return addDebugHeaders(response); | 92 | return addDebugHeaders(response); | ||
| 102 | } | 93 | } | ||
| 103 | - | 94 | + | ||
| 104 | // Protected routes: ensure authentication | 95 | // Protected routes: ensure authentication | ||
| 105 | if (token === null) { | 96 | if (token === null) { | ||
| 106 | console.debug("No session token found"); | 97 | console.debug("No session token found"); | ||
| @@ -116,8 +107,6 @@ export const handle: Handle = async ({ event, resolve }) => { | |||||
| 116 | setSessionTokenCookie(event, token, session.expiresAt); | 107 | setSessionTokenCookie(event, token, session.expiresAt); | ||
| 117 | event.locals.session = session; | 108 | event.locals.session = session; | ||
| 118 | event.locals.user = user; | 109 | event.locals.user = user; | ||
| 119 | - | const response = await resolve(event); | |||
| 120 | - | return addDebugHeaders(response); | |||
| 121 | } else { | 110 | } else { | ||
| 122 | console.debug("Invalid session token"); | 111 | console.debug("Invalid session token"); | ||
| 123 | deleteSessionTokenCookie(event); | 112 | deleteSessionTokenCookie(event); | ||
| @@ -126,4 +115,21 @@ export const handle: Handle = async ({ event, resolve }) => { | |||||
| 126 | const response = new Response("Unauthorized", { status: 401 }); | 115 | const response = new Response("Unauthorized", { status: 401 }); | ||
| 127 | return addDebugHeaders(response); | 116 | return addDebugHeaders(response); | ||
| 128 | } | 117 | } | ||
| 118 | + | ||||
| 119 | + | // After authentication, ensure SSE endpoint does not trigger rate limiting | |||
| 120 | + | if (event.url.pathname.startsWith('/api/sse') && !event.locals.user) { | |||
| 121 | + | console.debug("SSE endpoint accessed without valid session, returning 401 without rate limiting"); | |||
| 122 | + | return addDebugHeaders(new Response("Unauthorized", { status: 401 })); | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // For any request without an authenticated user, apply rate limiting | |||
| 126 | + | if (!event.locals.user) { | |||
| 127 | + | const rateLimitResponse = await handleRateLimit(event); | |||
| 128 | + | if (rateLimitResponse) { | |||
| 129 | + | return rateLimitResponse; | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | const response = await resolve(event); | |||
| 134 | + | return addDebugHeaders(response); | |||
| 129 | }; ⏎̸ | 135 | }; ⏎̸ | ||
M src/lib/api/session.cookie.ts +25 -14
| @@ -1,20 +1,35 @@ | |||
| 1 | 1 | import type { Cookies } from "@sveltejs/kit"; | |
| 2 | 2 | ||
| 3 | + | // Helper function to determine cookie domain | |
| 4 | + | function getCookieDomain(): string | undefined { | |
| 5 | + | if (process.env.NODE_ENV === 'production') { | |
| 6 | + | // Check for Cloudflare Pages domain | |
| 7 | + | if (process.env.CF_PAGES_URL) { | |
| 8 | + | console.debug("Cloudflare Pages URL:", process.env.CF_PAGES_URL); | |
| 9 | + | // For preview deployments, use host-only cookie | |
| 10 | + | if (process.env.CF_PAGES_BRANCH !== 'main') { | |
| 11 | + | return undefined; | |
| 12 | + | } | |
| 13 | + | // For production, use the main domain | |
| 14 | + | return '.pdraim.pages.dev'; | |
| 15 | + | } | |
| 16 | + | // Fallback for custom domain | |
| 17 | + | return '.pdraim.org'; | |
| 18 | + | } | |
| 19 | + | return undefined; | |
| 20 | + | } | |
| 21 | + | ||
| 3 | 22 | export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: string, expiresAt: number): void { | |
| 4 | 23 | console.debug("Setting session cookie with expiry:", new Date(expiresAt)); | |
| 5 | 24 | ||
| 6 | - | // Get the current domain based on environment and host | |
| 7 | - | const domain = process.env.NODE_ENV === 'production' | |
| 8 | - | ? (process.env.VERCEL_URL?.includes('pages.dev') ? '.pdraim.pages.dev' : '.pdraim.org') | |
| 9 | - | : undefined; // Use default domain in development | |
| 10 | - | ||
| 25 | + | const domain = getCookieDomain(); | |
| 11 | 26 | console.debug("Setting cookie for domain:", domain); | |
| 12 | 27 | ||
| 13 | 28 | cookies.set("session", token, { | |
| 14 | 29 | httpOnly: true, | |
| 15 | 30 | sameSite: "lax", | |
| 16 | - | secure: process.env.NODE_ENV === 'production', // Only use secure in production | |
| 17 | - | domain, // Set domain based on environment | |
| 31 | + | secure: process.env.NODE_ENV === 'production', | |
| 32 | + | domain, | |
| 18 | 33 | expires: new Date(expiresAt), | |
| 19 | 34 | path: "/" | |
| 20 | 35 | }); | |
| @@ -23,18 +38,14 @@ export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: | |||
| 23 | 38 | export function deleteSessionTokenCookie({ cookies }: { cookies: Cookies }): void { | |
| 24 | 39 | console.debug("Deleting session cookie"); | |
| 25 | 40 | ||
| 26 | - | // Get the current domain based on environment and host | |
| 27 | - | const domain = process.env.NODE_ENV === 'production' | |
| 28 | - | ? (process.env.VERCEL_URL?.includes('pages.dev') ? '.pdraim.pages.dev' : '.pdraim.org') | |
| 29 | - | : undefined; // Use default domain in development | |
| 30 | - | ||
| 41 | + | const domain = getCookieDomain(); | |
| 31 | 42 | console.debug("Deleting cookie for domain:", domain); | |
| 32 | 43 | ||
| 33 | 44 | cookies.set("session", "", { | |
| 34 | 45 | httpOnly: true, | |
| 35 | 46 | sameSite: "lax", | |
| 36 | - | secure: process.env.NODE_ENV === 'production', // Only use secure in production | |
| 37 | - | domain, // Set domain based on environment | |
| 47 | + | secure: process.env.NODE_ENV === 'production', | |
| 48 | + | domain, | |
| 38 | 49 | maxAge: 0, | |
| 39 | 50 | path: "/" | |
| 40 | 51 | }); | |
| @@ -1,20 +1,35 @@ | |||||
| 1 | import type { Cookies } from "@sveltejs/kit"; | 1 | import type { Cookies } from "@sveltejs/kit"; | ||
| 2 | 2 | ||||
| 3 | + | // Helper function to determine cookie domain | |||
| 4 | + | function getCookieDomain(): string | undefined { | |||
| 5 | + | if (process.env.NODE_ENV === 'production') { | |||
| 6 | + | // Check for Cloudflare Pages domain | |||
| 7 | + | if (process.env.CF_PAGES_URL) { | |||
| 8 | + | console.debug("Cloudflare Pages URL:", process.env.CF_PAGES_URL); | |||
| 9 | + | // For preview deployments, use host-only cookie | |||
| 10 | + | if (process.env.CF_PAGES_BRANCH !== 'main') { | |||
| 11 | + | return undefined; | |||
| 12 | + | } | |||
| 13 | + | // For production, use the main domain | |||
| 14 | + | return '.pdraim.pages.dev'; | |||
| 15 | + | } | |||
| 16 | + | // Fallback for custom domain | |||
| 17 | + | return '.pdraim.org'; | |||
| 18 | + | } | |||
| 19 | + | return undefined; | |||
| 20 | + | } | |||
| 21 | + | ||||
| 3 | export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: string, expiresAt: number): void { | 22 | export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: string, expiresAt: number): void { | ||
| 4 | console.debug("Setting session cookie with expiry:", new Date(expiresAt)); | 23 | console.debug("Setting session cookie with expiry:", new Date(expiresAt)); | ||
| 5 | 24 | ||||
| 6 | - | // Get the current domain based on environment and host | 25 | + | const domain = getCookieDomain(); |
| 7 | - | const domain = process.env.NODE_ENV === 'production' | |||
| 8 | - | ? (process.env.VERCEL_URL?.includes('pages.dev') ? '.pdraim.pages.dev' : '.pdraim.org') | |||
| 9 | - | : undefined; // Use default domain in development | |||
| 10 | - | ||||
| 11 | console.debug("Setting cookie for domain:", domain); | 26 | console.debug("Setting cookie for domain:", domain); | ||
| 12 | 27 | ||||
| 13 | cookies.set("session", token, { | 28 | cookies.set("session", token, { | ||
| 14 | httpOnly: true, | 29 | httpOnly: true, | ||
| 15 | sameSite: "lax", | 30 | sameSite: "lax", | ||
| 16 | - | secure: process.env.NODE_ENV === 'production', // Only use secure in production | 31 | + | secure: process.env.NODE_ENV === 'production', |
| 17 | - | domain, // Set domain based on environment | 32 | + | domain, |
| 18 | expires: new Date(expiresAt), | 33 | expires: new Date(expiresAt), | ||
| 19 | path: "/" | 34 | path: "/" | ||
| 20 | }); | 35 | }); | ||
| @@ -23,18 +38,14 @@ export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: | |||||
| 23 | export function deleteSessionTokenCookie({ cookies }: { cookies: Cookies }): void { | 38 | export function deleteSessionTokenCookie({ cookies }: { cookies: Cookies }): void { | ||
| 24 | console.debug("Deleting session cookie"); | 39 | console.debug("Deleting session cookie"); | ||
| 25 | 40 | ||||
| 26 | - | // Get the current domain based on environment and host | 41 | + | const domain = getCookieDomain(); |
| 27 | - | const domain = process.env.NODE_ENV === 'production' | |||
| 28 | - | ? (process.env.VERCEL_URL?.includes('pages.dev') ? '.pdraim.pages.dev' : '.pdraim.org') | |||
| 29 | - | : undefined; // Use default domain in development | |||
| 30 | - | ||||
| 31 | console.debug("Deleting cookie for domain:", domain); | 42 | console.debug("Deleting cookie for domain:", domain); | ||
| 32 | 43 | ||||
| 33 | cookies.set("session", "", { | 44 | cookies.set("session", "", { | ||
| 34 | httpOnly: true, | 45 | httpOnly: true, | ||
| 35 | sameSite: "lax", | 46 | sameSite: "lax", | ||
| 36 | - | secure: process.env.NODE_ENV === 'production', // Only use secure in production | 47 | + | secure: process.env.NODE_ENV === 'production', |
| 37 | - | domain, // Set domain based on environment | 48 | + | domain, |
| 38 | maxAge: 0, | 49 | maxAge: 0, | ||
| 39 | path: "/" | 50 | path: "/" | ||
| 40 | }); | 51 | }); | ||
A src/lib/utils/sseClient.ts +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | /** | |
| 2 | + | * Utility to initialize an SSE connection after ensuring the session cookie is present. | |
| 3 | + | * This helps avoid unauthenticated SSE requests that trigger rate limiting on the server. | |
| 4 | + | */ | |
| 5 | + | ||
| 6 | + | export function initSSE(url: string, maxRetries: number = 5, retryDelay: number = 500): Promise<EventSource> { | |
| 7 | + | return new Promise((resolve, reject) => { | |
| 8 | + | let attempt = 0; | |
| 9 | + | const tryConnect = () => { | |
| 10 | + | if (document.cookie.includes('session=')) { | |
| 11 | + | console.debug('Session cookie found, initializing SSE connection'); | |
| 12 | + | const eventSource = new EventSource(url); | |
| 13 | + | resolve(eventSource); | |
| 14 | + | } else { | |
| 15 | + | attempt++; | |
| 16 | + | console.debug(`Session cookie not found. Attempt ${attempt} of ${maxRetries}, retrying in ${retryDelay}ms...`); | |
| 17 | + | if (attempt >= maxRetries) { | |
| 18 | + | reject(new Error('Session cookie not found after maximum retries')); | |
| 19 | + | } else { | |
| 20 | + | setTimeout(tryConnect, retryDelay); | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | }; | |
| 24 | + | tryConnect(); | |
| 25 | + | }); | |
| 26 | + | } ⏎̸ | |
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | /** | |||
| 2 | + | * Utility to initialize an SSE connection after ensuring the session cookie is present. | |||
| 3 | + | * This helps avoid unauthenticated SSE requests that trigger rate limiting on the server. | |||
| 4 | + | */ | |||
| 5 | + | ||||
| 6 | + | export function initSSE(url: string, maxRetries: number = 5, retryDelay: number = 500): Promise<EventSource> { | |||
| 7 | + | return new Promise((resolve, reject) => { | |||
| 8 | + | let attempt = 0; | |||
| 9 | + | const tryConnect = () => { | |||
| 10 | + | if (document.cookie.includes('session=')) { | |||
| 11 | + | console.debug('Session cookie found, initializing SSE connection'); | |||
| 12 | + | const eventSource = new EventSource(url); | |||
| 13 | + | resolve(eventSource); | |||
| 14 | + | } else { | |||
| 15 | + | attempt++; | |||
| 16 | + | console.debug(`Session cookie not found. Attempt ${attempt} of ${maxRetries}, retrying in ${retryDelay}ms...`); | |||
| 17 | + | if (attempt >= maxRetries) { | |||
| 18 | + | reject(new Error('Session cookie not found after maximum retries')); | |||
| 19 | + | } else { | |||
| 20 | + | setTimeout(tryConnect, retryDelay); | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | }; | |||
| 24 | + | tryConnect(); | |||
| 25 | + | }); | |||
| 26 | + | } ⏎̸ | |||