detached d4613e4

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 UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
3 files · +75 -32
M src/hooks.server.ts +24 -18
@@ -34,39 +34,30 @@ initializeDatabase()
3434 export const handle: Handle = async ({ event, resolve }) => {
3535 console.debug("Handling request:", event.url.pathname);
3636
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
4438 const isPublicChatRequest =
4539 event.url.pathname === '/api/chat/messages' &&
4640 event.url.searchParams.get('public') === 'true' &&
4741 event.request.method === 'GET';
48-
49- // Check if this is a public room request
5042 const isPublicRoomRequest =
5143 event.url.pathname.startsWith('/api/rooms/') &&
5244 event.url.searchParams.get('public') === 'true' &&
5345 event.request.method === 'GET';
5446
55- // Public routes that don't require authentication
47+ // Public routes that don't require authentication (removed '/api/sse' here)
5648 const publicRoutes = [
5749 "/",
5850 "/api/session/login",
5951 "/api/session/validate",
6052 "/api/register",
6153 "/login",
62- "/register",
63- "/api/sse"
54+ "/register"
6455 ];
6556
6657 // Get session token from cookies
6758 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
7061 const addDebugHeaders = (response: Response) => {
7162 if (event.url.pathname.startsWith('/api/sse')) {
7263 response.headers.set('X-Debug-Has-Token', Boolean(token).toString());
@@ -76,7 +67,7 @@ export const handle: Handle = async ({ event, resolve }) => {
7667 return response;
7768 };
7869
79- // Handle public requests separately: always clear session
70+ // Handle public chat/room requests
8071 if (isPublicChatRequest || isPublicRoomRequest) {
8172 console.debug("Public request accessed:", event.url.pathname);
8273 event.locals.user = null;
@@ -100,7 +91,7 @@ export const handle: Handle = async ({ event, resolve }) => {
10091 const response = await resolve(event);
10192 return addDebugHeaders(response);
10293 }
103-
94+
10495 // Protected routes: ensure authentication
10596 if (token === null) {
10697 console.debug("No session token found");
@@ -116,8 +107,6 @@ export const handle: Handle = async ({ event, resolve }) => {
116107 setSessionTokenCookie(event, token, session.expiresAt);
117108 event.locals.session = session;
118109 event.locals.user = user;
119- const response = await resolve(event);
120- return addDebugHeaders(response);
121110 } else {
122111 console.debug("Invalid session token");
123112 deleteSessionTokenCookie(event);
@@ -126,4 +115,21 @@ export const handle: Handle = async ({ event, resolve }) => {
126115 const response = new Response("Unauthorized", { status: 401 });
127116 return addDebugHeaders(response);
128117 }
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);
129135 }; ⏎̸
@@ -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 routes37+ // 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 authentication47+ // 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 cookies57 // 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 requests60+ // 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 session70+ // 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 authentication95 // 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 @@
11 import type { Cookies } from "@sveltejs/kit";
22
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+
322 export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: string, expiresAt: number): void {
423 console.debug("Setting session cookie with expiry:", new Date(expiresAt));
524
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();
1126 console.debug("Setting cookie for domain:", domain);
1227
1328 cookies.set("session", token, {
1429 httpOnly: true,
1530 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,
1833 expires: new Date(expiresAt),
1934 path: "/"
2035 });
@@ -23,18 +38,14 @@ export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token:
2338 export function deleteSessionTokenCookie({ cookies }: { cookies: Cookies }): void {
2439 console.debug("Deleting session cookie");
2540
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();
3142 console.debug("Deleting cookie for domain:", domain);
3243
3344 cookies.set("session", "", {
3445 httpOnly: true,
3546 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,
3849 maxAge: 0,
3950 path: "/"
4051 });
@@ -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 host25+ 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 production31+ secure: process.env.NODE_ENV === 'production',
17- domain, // Set domain based on environment32+ 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 host41+ 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 production47+ secure: process.env.NODE_ENV === 'production',
37- domain, // Set domain based on environment48+ 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+} ⏎̸

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