Skip to content

Commit 0f32fd2

Browse files
committed
feat: add rate limiter
Signed-off-by: Raúl Santos <[email protected]>
1 parent 8f946a4 commit 0f32fd2

File tree

8 files changed

+555
-6
lines changed

8 files changed

+555
-6
lines changed

frontend/nuxt.config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import primevue from './setup/primevue';
88
import echarts from './setup/echarts';
99
import caching from './setup/caching';
1010
import sitemap from './setup/sitemap';
11+
import rateLimiter from './setup/rate-limiter';
1112

1213
const isProduction = process.env.NUXT_APP_ENV === 'production';
1314
const isDevelopment = process.env.NODE_ENV === 'development';
@@ -52,7 +53,7 @@ export default defineNuxtConfig({
5253
primevue,
5354
echarts,
5455
runtimeConfig: {
55-
// These are are only available on the server-side and can be overridden by the .env file
56+
// These are only available on the server-side and can be overridden by the .env file
5657
appEnv: process.env.APP_ENV,
5758
tinybirdBaseUrl: 'https://api.us-west-2.aws.tinybird.co',
5859
tinybirdToken: '',
@@ -79,6 +80,7 @@ export default defineNuxtConfig({
7980
cmDbPassword: 'example',
8081
cmDbDatabase: 'crowd-web',
8182
dataCopilotDefaultSegmentId: '',
83+
rateLimiter: rateLimiter,
8284
// These are also exposed on the client-side
8385
public: {
8486
apiBase: '/api',

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@pinia/nuxt": "^0.11.2",
4040
"@popperjs/core": "^2.11.8",
4141
"@primevue/themes": "^4.4.1",
42+
"@redis/client": "^5.9.0",
4243
"@tanstack/vue-query": "^5.90.5",
4344
"@types/jsonwebtoken": "^9.0.10",
4445
"@vuelidate/core": "^2.0.3",

frontend/pnpm-lock.yaml

Lines changed: 16 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) 2025 The Linux Foundation and each contributor.
2+
// SPDX-License-Identifier: MIT
3+
4+
import type { H3Event } from 'h3';
5+
import { RedisClientType } from '@redis/client';
6+
import { checkRateLimit } from '../utils/rate-limiter';
7+
import { RateLimiterConfig } from '~~/server/types/rate-limiter';
8+
9+
/**
10+
* This is a rate-limiting middleware that checks incoming requests against the configured rate
11+
* limits and blocks requests that exceed the limits. The defineEventHandler entrypoint is simple
12+
* and delegates the actual logic to the handleRateLimiting function for easier testing, allowing
13+
* injection of mocked dependencies and configuration.
14+
*
15+
* Features:
16+
* - Uses Redis for distributed rate limiting
17+
* - Hashes IP addresses for GDPR compliance
18+
* - Supports per-route and per-method limits
19+
* - Adds rate limit headers to responses
20+
*/
21+
export default defineEventHandler(async (event: H3Event) => {
22+
const config = useRuntimeConfig();
23+
const rateLimiterConfig = config.rateLimiter as RateLimiterConfig;
24+
25+
// getRedisClient memoizes the client instance, so it's not a problem to call it multiple times.
26+
const redisClient = await getRedisClient(config.redisUrl, rateLimiterConfig.redisDatabase, true);
27+
28+
await handleRateLimiting(event, rateLimiterConfig, redisClient);
29+
});
30+
31+
/**
32+
* Handles rate limiting for the given event and rate limiter configuration.
33+
*
34+
* @param event - The H3 event object for the incoming request.
35+
* @param rateLimiterConfig - The rate limiter configuration to use.
36+
* @param redisClient - The Redis client instance to use for rate limiting.
37+
*/
38+
export async function handleRateLimiting(
39+
event: H3Event,
40+
rateLimiterConfig: RateLimiterConfig,
41+
redisClient: RedisClientType,
42+
) {
43+
// Skip rate limiting if disabled
44+
if (!rateLimiterConfig.enabled) {
45+
return;
46+
}
47+
48+
try {
49+
const result = await checkRateLimit(event, rateLimiterConfig, redisClient);
50+
51+
setResponseHeaders(event, {
52+
['X-RateLimit-Limit']: result.limit.toString(),
53+
['X-RateLimit-Remaining']: result.remaining.toString(),
54+
['X-RateLimit-Reset']: result.resetIn.toString(),
55+
});
56+
57+
// Block request if rate limit exceeded
58+
if (!result.allowed) {
59+
throw createError({
60+
statusCode: 429,
61+
statusMessage: 'Too Many Requests',
62+
message: `Rate limit exceeded. Please wait ${result.resetIn} seconds before trying again.`,
63+
});
64+
}
65+
} catch (error) {
66+
// If it's already a 429 error, re-throw it
67+
if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 429) {
68+
throw error;
69+
}
70+
71+
// Log other errors but don't block the request. This way the app keeps working even if Redis
72+
// is down or the rate limiter fails for some other reason.
73+
console.error('Rate limiter error:', error);
74+
}
75+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2025 The Linux Foundation and each contributor.
2+
// SPDX-License-Identifier: MIT
3+
4+
/**
5+
* HTTP methods supported by the rate limiter.
6+
*/
7+
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
8+
9+
/**
10+
* Rate limit configuration for a specific route.
11+
*/
12+
export interface RateLimitRule {
13+
/**
14+
* The route pattern to match (supports wildcards).
15+
* Examples: '/api/*', '/api/report', '/api/auth/*'
16+
*/
17+
route: string;
18+
19+
/**
20+
* HTTP methods this rule applies to. If not specified, it applies to all methods.
21+
*/
22+
methods?: HttpMethod[];
23+
24+
/**
25+
* Maximum number of requests allowed within the window.
26+
*/
27+
maxRequests: number;
28+
29+
/**
30+
* Time window in seconds.
31+
*/
32+
windowSeconds: number;
33+
}
34+
35+
/**
36+
* Global rate limiter configuration.
37+
*/
38+
export interface RateLimiterConfig {
39+
/**
40+
* Whether to enable the rate limiter.
41+
* @default true
42+
*/
43+
enabled?: boolean;
44+
45+
/**
46+
* Default rate limit applied to all routes not matching specific rules.
47+
*/
48+
defaultLimit: {
49+
maxRequests: number;
50+
windowSeconds: number;
51+
};
52+
53+
/**
54+
* Secret used for hashing IP addresses for GDPR compliance.
55+
*/
56+
secret: string;
57+
58+
/**
59+
* Redis database number to use for rate limiting.
60+
*/
61+
redisDatabase: number;
62+
63+
/**
64+
* Route-specific rate limit rules. Rules are evaluated in order; first match wins.
65+
*/
66+
rules: RateLimitRule[];
67+
}
68+
69+
/**
70+
* Rate limit check result.
71+
*/
72+
export interface RateLimitResult {
73+
/**
74+
* Whether the request is allowed.
75+
*/
76+
allowed: boolean;
77+
78+
/**
79+
* Maximum requests allowed in the window.
80+
*/
81+
limit: number;
82+
83+
/**
84+
* Remaining requests in the current window.
85+
*/
86+
remaining: number;
87+
88+
/**
89+
* Time until the rate limit resets (in seconds).
90+
*/
91+
resetIn: number;
92+
93+
/**
94+
* Total number of requests made in the current window.
95+
*/
96+
current: number;
97+
}

0 commit comments

Comments
 (0)