Skip to content

Commit 2ebbe11

Browse files
committed
feat: add rate limiter
Signed-off-by: Raúl Santos <[email protected]>
1 parent 66b7b06 commit 2ebbe11

File tree

8 files changed

+659
-6
lines changed

8 files changed

+659
-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: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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 { getRedisClient } from '../utils/redis-client';
8+
import { RateLimiterConfig } from '~~/server/types/rate-limiter';
9+
10+
/**
11+
* This is a rate-limiting middleware that checks incoming requests against the configured rate
12+
* limits and blocks requests that exceed the limits. The defineEventHandler entrypoint is simple
13+
* and delegates the actual logic to the handleRateLimiting function for easier testing, allowing
14+
* injection of mocked dependencies and configuration.
15+
*
16+
* Features:
17+
* - Uses Redis for distributed rate limiting
18+
* - Hashes IP addresses for GDPR compliance
19+
* - Supports per-route and per-method limits
20+
* - Adds rate limit headers to responses
21+
*/
22+
export default defineEventHandler(async (event: H3Event) => {
23+
const config = useRuntimeConfig();
24+
const rateLimiterConfig = config.rateLimiter as RateLimiterConfig;
25+
26+
// getRedisClient memoizes the client instance, so it's not a problem to call it multiple times.
27+
const redisClient = await getRedisClient(config.redisUrl, rateLimiterConfig.redisDatabase, true);
28+
29+
await handleRateLimiting(event, rateLimiterConfig, redisClient);
30+
});
31+
32+
/**
33+
* Handles rate limiting for the given event and rate limiter configuration.
34+
*
35+
* @param event - The H3 event object for the incoming request.
36+
* @param rateLimiterConfig - The rate limiter configuration to use.
37+
* @param redisClient - The Redis client instance to use for rate limiting.
38+
*/
39+
export async function handleRateLimiting(
40+
event: H3Event,
41+
rateLimiterConfig: RateLimiterConfig,
42+
redisClient: RedisClientType,
43+
) {
44+
// Skip rate limiting if disabled
45+
if (!rateLimiterConfig.enabled) {
46+
return;
47+
}
48+
49+
try {
50+
const result = await checkRateLimit(event, rateLimiterConfig, redisClient);
51+
52+
setResponseHeaders(event, {
53+
['X-RateLimit-Limit']: result.limit.toString(),
54+
['X-RateLimit-Remaining']: result.remaining.toString(),
55+
['X-RateLimit-Reset']: result.resetIn.toString(),
56+
});
57+
58+
// Block request if rate limit exceeded
59+
if (!result.allowed) {
60+
throw createError({
61+
statusCode: 429,
62+
statusMessage: 'Too Many Requests',
63+
message: `Rate limit exceeded. Please wait ${result.resetIn} seconds before trying again.`,
64+
});
65+
}
66+
} catch (error) {
67+
// If it's already a 429 error, re-throw it
68+
if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 429) {
69+
throw error;
70+
}
71+
72+
// Log other errors but don't block the request. This way the app keeps working even if Redis
73+
// is down or the rate limiter fails for some other reason.
74+
console.error('Rate limiter error:', error);
75+
}
76+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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+
* Exclusion rule for routes that should bypass rate limiting.
37+
*/
38+
export interface RateLimitExclusion {
39+
/**
40+
* The route pattern to exclude (supports wildcards).
41+
* Examples: '/api/health', '/api/public/*', '/_nuxt/*'
42+
*/
43+
route: string;
44+
45+
/**
46+
* HTTP methods this exclusion applies to. If not specified, excludes all methods.
47+
*/
48+
methods?: HttpMethod[];
49+
}
50+
51+
/**
52+
* Global rate limiter configuration.
53+
*/
54+
export interface RateLimiterConfig {
55+
/**
56+
* Whether to enable the rate limiter.
57+
* @default true
58+
*/
59+
enabled?: boolean;
60+
61+
/**
62+
* Default rate limit applied to all routes not matching specific rules.
63+
*/
64+
defaultLimit: {
65+
maxRequests: number;
66+
windowSeconds: number;
67+
};
68+
69+
/**
70+
* Secret used for hashing IP addresses for GDPR compliance.
71+
*/
72+
secret: string;
73+
74+
/**
75+
* Redis database number to use for rate limiting.
76+
*/
77+
redisDatabase: number;
78+
79+
/**
80+
* Route-specific rate limit rules. Rules are evaluated in order; first match wins.
81+
*/
82+
rules: RateLimitRule[];
83+
84+
/**
85+
* Routes to exclude from rate limiting. Exclusions are checked before rules.
86+
* If a request matches an exclusion, it bypasses rate limiting entirely.
87+
*/
88+
exclusions?: RateLimitExclusion[];
89+
}
90+
91+
/**
92+
* Rate limit check result.
93+
*/
94+
export interface RateLimitResult {
95+
/**
96+
* Whether the request is allowed.
97+
*/
98+
allowed: boolean;
99+
100+
/**
101+
* Maximum requests allowed in the window.
102+
*/
103+
limit: number;
104+
105+
/**
106+
* Remaining requests in the current window.
107+
*/
108+
remaining: number;
109+
110+
/**
111+
* Time until the rate limit resets (in seconds).
112+
*/
113+
resetIn: number;
114+
115+
/**
116+
* Total number of requests made in the current window.
117+
*/
118+
current: number;
119+
}

0 commit comments

Comments
 (0)