Skip to content

Commit b1135a7

Browse files
committed
chore: rate limiting
1 parent 61a9cec commit b1135a7

File tree

2 files changed

+116
-32
lines changed

2 files changed

+116
-32
lines changed

src/middleware/index.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,41 @@
11
import { createSupabaseServerInstance } from '@/db/supabase.client';
2-
import { defineMiddleware } from 'astro:middleware';
2+
import { sequence, defineMiddleware } from 'astro:middleware';
3+
import { checkRateLimit, setRateLimitCookie } from '../services/rateLimiter';
4+
5+
// Define rate limit configurations: path -> seconds
6+
const RATE_LIMIT_CONFIG: { [path: string]: number } = {
7+
'/api/auth': 10, // Default existing behavior: 10 seconds for /api/auth and its sub-routes
8+
// Add other specific path configurations here, e.g.:
9+
// '/api/heavy-operation': 60,
10+
};
11+
12+
const rateLimiter = defineMiddleware(async ({ cookies, url }, next) => {
13+
const currentPath = url.pathname;
14+
let matchedPath: string | undefined;
15+
let matchedLimit: number | undefined;
16+
17+
// Find if the current path matches any configured rate-limited paths
18+
for (const pathPrefix in RATE_LIMIT_CONFIG) {
19+
if (currentPath.startsWith(pathPrefix)) {
20+
matchedPath = pathPrefix; // Use the configured prefix as the key for rate limiting
21+
matchedLimit = RATE_LIMIT_CONFIG[pathPrefix];
22+
break;
23+
}
24+
}
25+
26+
if (matchedPath && matchedLimit !== undefined) {
27+
if (checkRateLimit(cookies, matchedPath, matchedLimit)) {
28+
setRateLimitCookie(cookies, matchedPath, matchedLimit);
29+
return next();
30+
}
31+
return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), {
32+
status: 429,
33+
headers: { 'Content-Type': 'application/json' },
34+
});
35+
}
36+
37+
return next();
38+
});
339

440
// Public paths that don't require authentication
541
const PUBLIC_PATHS = [
@@ -17,7 +53,7 @@ const PUBLIC_PATHS = [
1753
'/privacy/en',
1854
];
1955

20-
export const onRequest = defineMiddleware(
56+
const validateRequest = defineMiddleware(
2157
async ({ locals, cookies, url, request, redirect }, next) => {
2258
try {
2359
const supabase = createSupabaseServerInstance({
@@ -70,3 +106,5 @@ export const onRequest = defineMiddleware(
70106
}
71107
},
72108
);
109+
110+
export const onRequest = sequence(rateLimiter, validateRequest);

src/services/rateLimiter.ts

Lines changed: 76 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,82 @@
1-
const RATE_LIMIT_COOKIE_NAME = 'login_attempt_timestamp';
2-
const RATE_LIMIT_SECONDS = 10;
3-
4-
/**
5-
* Checks if the request is rate-limited based on a timestamp in an HTTP-only cookie.
6-
* @param headers The Headers object from the request.
7-
* @returns True if the request is allowed, false if it's rate-limited.
8-
*/
9-
export function checkRateLimit(headers: Headers): boolean {
10-
const lastAttemptTimestamp = headers
11-
.get('cookie')
12-
?.split('; ')
13-
.find((row) => row.startsWith(`${RATE_LIMIT_COOKIE_NAME}=`))
14-
?.split('=')[1];
15-
16-
if (lastAttemptTimestamp) {
17-
const lastAttemptTime = parseInt(lastAttemptTimestamp, 10);
18-
const currentTime = Date.now();
19-
if (currentTime - lastAttemptTime < RATE_LIMIT_SECONDS * 1000) {
20-
return false; // Rate-limited
1+
import { type AstroCookies } from 'astro';
2+
3+
const RATE_LIMIT_COOKIE_NAME = 'app_rl_v1';
4+
const DEFAULT_RATE_LIMIT_SECONDS = 10;
5+
6+
// Type for the cookie value: a map of routes to their last access timestamps
7+
type RateLimitData = {
8+
[route: string]: number; // route: timestamp
9+
};
10+
11+
export function checkRateLimit(
12+
cookies: AstroCookies,
13+
route: string,
14+
rateLimitSeconds: number = DEFAULT_RATE_LIMIT_SECONDS,
15+
): boolean {
16+
const cookie = cookies.get(RATE_LIMIT_COOKIE_NAME);
17+
if (!cookie?.value) {
18+
return true; // No cookie, or empty cookie value, allow
19+
}
20+
21+
try {
22+
const decodedValue = atob(cookie.value);
23+
const rateLimitData = JSON.parse(decodedValue) as RateLimitData;
24+
const lastAccessTime = rateLimitData[route];
25+
26+
if (!lastAccessTime) {
27+
return true; // No timestamp for this route yet, allow
28+
}
29+
30+
const timeSinceLastAccess = Date.now() - lastAccessTime;
31+
if (timeSinceLastAccess < rateLimitSeconds * 1000) {
32+
console.log(
33+
`Rate limit exceeded for route: ${route}. Time since last access: ${timeSinceLastAccess}ms, Limit: ${rateLimitSeconds * 1000}ms`,
34+
);
35+
return false; // Rate limit exceeded
2136
}
37+
return true; // Rate limit not exceeded
38+
} catch (error) {
39+
console.error('Error decoding or parsing rate limit cookie:', error);
40+
// If there's an error (e.g., malformed cookie), allow the request and overwrite the cookie later.
41+
// Alternatively, you could block to be safer, depending on requirements.
42+
return true;
2243
}
23-
return true; // Allowed
2444
}
2545

26-
/**
27-
* Sets an HTTP-only cookie with the current timestamp to track the last login attempt.
28-
* @param responseHeaders The Headers object to add the Set-Cookie header to.
29-
*/
30-
export function setRateLimitCookie(responseHeaders: Headers): void {
46+
export function setRateLimitCookie(
47+
cookies: AstroCookies,
48+
route: string,
49+
rateLimitSeconds: number = DEFAULT_RATE_LIMIT_SECONDS,
50+
): void {
3151
const currentTime = Date.now();
32-
responseHeaders.append(
33-
'Set-Cookie',
34-
`${RATE_LIMIT_COOKIE_NAME}=${currentTime}; HttpOnly; Path=/; Max-Age=${RATE_LIMIT_SECONDS}; SameSite=Lax`,
35-
);
52+
let rateLimitData: RateLimitData = {};
53+
54+
const existingCookie = cookies.get(RATE_LIMIT_COOKIE_NAME);
55+
if (existingCookie?.value) {
56+
try {
57+
const decodedValue = atob(existingCookie.value);
58+
rateLimitData = JSON.parse(decodedValue) as RateLimitData;
59+
} catch (error) {
60+
console.error('Error decoding or parsing existing rate limit cookie:', error);
61+
// If cookie is malformed, start fresh
62+
rateLimitData = {};
63+
}
64+
}
65+
66+
rateLimitData[route] = currentTime;
67+
68+
// Clean up old entries (optional, but good for cookie size management)
69+
// This example doesn't include cleanup, but you might want to add it if routes are dynamic or numerous.
70+
71+
try {
72+
const newCookieValue = btoa(JSON.stringify(rateLimitData));
73+
cookies.set(RATE_LIMIT_COOKIE_NAME, newCookieValue, {
74+
path: '/',
75+
maxAge: Math.max(rateLimitSeconds, DEFAULT_RATE_LIMIT_SECONDS) * 2, // Cookie should last longer than the longest rate limit
76+
sameSite: 'lax',
77+
httpOnly: true, // Make cookie httpOnly for security
78+
});
79+
} catch (error) {
80+
console.error('Error encoding or setting rate limit cookie:', error);
81+
}
3682
}

0 commit comments

Comments
 (0)