Skip to content

Commit 3e8beb8

Browse files
authored
Merge pull request #124 from kevcomparadise/feat/per-route-rate-limiting
(feat) Per-Route Rate Limiting
2 parents f49c4ab + 4373721 commit 3e8beb8

File tree

14 files changed

+263
-22
lines changed

14 files changed

+263
-22
lines changed

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,49 @@ export default defineTask({
258258
```
259259
Make sure to configure `ipTTL` in your `nuxt.config.ts` under `nuxtApiShield` if you wish to use a value different from the default (7 days). Setting `ipTTL: 0` (or any non-positive number) in your config will disable this cleanup task. The `RateLimit` type should be available via `#imports` if your module exports it or makes it available to Nuxt's auto-import system.
260260

261+
## Per-Route Rate Limiting
262+
263+
`nuxt-api-shield` supports **per-route rate limiting**, allowing you to define custom limits for specific API endpoints while keeping a global default configuration for all other routes.
264+
265+
This is useful when certain endpoints (such as `/api/login`, `/api/auth`, or `/api/payment`) require stricter protection.
266+
267+
---
268+
269+
### Configuration Example
270+
271+
The `routes` option accepts a mixed array:
272+
273+
- **String:** applies the **global rate limit configuration**
274+
- **Object:** applies **custom per-route limits**
275+
276+
```ts
277+
export default defineNuxtConfig({
278+
modules: ['nuxt-api-shield'],
279+
280+
nuxtApiShield: {
281+
limit: {
282+
max: 12,
283+
duration: 108,
284+
ban: 3600
285+
},
286+
287+
routes: [
288+
// 1. String: uses the global default limit
289+
'/api/example',
290+
291+
// 2. Object: custom rate limit for a specific route
292+
{
293+
path: '/api/example-per-route',
294+
max: 5, // custom max requests
295+
duration: 10 // custom duration (seconds)
296+
// ⚠️ "ban" always uses the global value
297+
}
298+
],
299+
}
300+
})
301+
```
302+
303+
261304
## Important Considerations
262305

263306
### Data Privacy (IP Address Storage)

playground/nuxt.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ export default defineNuxtConfig({
2323
duration: 10,
2424
ban: 30,
2525
},
26+
routes: [
27+
'/api/example',
28+
{
29+
path: '/api/example-per-route',
30+
max: 5,
31+
duration: 10,
32+
},
33+
],
2634
delayOnBan: true,
2735
retryAfterHeader: true,
2836
log: {
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default defineEventHandler(async () => {
2+
return { result: 'Hello World !' }
3+
})

src/module.ts

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,7 @@ import {
66
addServerImports,
77
} from '@nuxt/kit'
88
import defu from 'defu'
9-
import type { LogEntry } from './runtime/server/types/LogEntry'
10-
11-
export interface ModuleOptions {
12-
limit: {
13-
max: number
14-
duration: number
15-
ban: number
16-
}
17-
delayOnBan: boolean
18-
errorMessage: string
19-
retryAfterHeader: boolean
20-
log?: LogEntry
21-
routes: string[]
22-
}
9+
import type { ModuleOptions } from './type'
2310

2411
export default defineNuxtModule<ModuleOptions>({
2512
meta: {

src/runtime/server/middleware/shield.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,35 @@ import {
88
useStorage,
99
} from '#imports'
1010
import type { RateLimit } from '../types/RateLimit'
11+
import type { ModuleOptions } from '~/src/type'
12+
import {
13+
extractRoutePaths,
14+
getRouteLimit,
15+
hasRouteLimit,
16+
} from '../utils/routes'
17+
import createKey from '../utils/createKey'
1118

1219
export default defineEventHandler(async (event) => {
13-
const config = useRuntimeConfig().public.nuxtApiShield
20+
const config = useRuntimeConfig().public.nuxtApiShield as ModuleOptions
1421
const url = getRequestURL(event)
22+
23+
/**
24+
* Get Route limit, if match with configured route its override default limit configuration
25+
*/
26+
const routeLimit = getRouteLimit(url?.pathname, config)
27+
const isRouteLimit = hasRouteLimit(url?.pathname, config)
28+
const routePaths = extractRoutePaths(config.routes || [])
1529
if (!url?.pathname?.startsWith('/api/')
16-
|| (config.routes?.length && !config.routes.some(route => url.pathname?.startsWith(route)))) {
30+
|| (routePaths.length && !routePaths.some(path => url.pathname.startsWith(path)))) {
1731
return
1832
}
1933

2034
const shieldStorage = useStorage('shield')
2135
const requestIP = getRequestIP(event, { xForwardedFor: true }) || 'unKnownIP'
22-
const banKey = `ban:${requestIP}`
36+
const banKey = createKey({
37+
ipAddress: requestIP,
38+
prefix: 'ban',
39+
})
2340
const bannedUntilRaw = await shieldStorage.getItem(banKey)
2441
const bannedUntil = typeof bannedUntilRaw === 'number' ? bannedUntilRaw : Number(bannedUntilRaw)
2542

@@ -39,12 +56,16 @@ export default defineEventHandler(async (event) => {
3956
await shieldStorage.removeItem(banKey)
4057
}
4158

42-
const ipKey = `ip:${requestIP}`
59+
const ipKey = createKey({
60+
ipAddress: requestIP,
61+
path: isRouteLimit ? url?.pathname : undefined,
62+
})
63+
4364
const req = await shieldStorage.getItem(ipKey) as RateLimit
4465
const now = Date.now()
4566

4667
// Check if a new request is outside the duration window
47-
if (!req || (now - req.time) / 1000 >= config.limit.duration) {
68+
if (!req || (now - req.time) / 1000 >= routeLimit.duration) {
4869
// If no record exists, or the duration has expired, reset the counter and timestamp
4970
await shieldStorage.setItem(ipKey, {
5071
count: 1,
@@ -58,15 +79,15 @@ export default defineEventHandler(async (event) => {
5879
shieldLog(req, requestIP, url.toString())
5980

6081
// Check if the new count triggers a rate limit
61-
if (req.count > config.limit.max) {
62-
const banUntil = now + config.limit.ban * 1e3
82+
if (req.count > routeLimit.max) {
83+
const banUntil = now + routeLimit.ban * 1e3
6384
await shieldStorage.setItem(banKey, banUntil)
6485
await shieldStorage.setItem(ipKey, {
6586
count: 1,
6687
time: now,
6788
})
6889
if (config.retryAfterHeader) {
69-
event.node.res.setHeader('Retry-After', config.limit.ban)
90+
event.node.res.setHeader('Retry-After', routeLimit.ban)
7091
}
7192
throw createError({
7293
statusCode: 429,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
type CreateKeyArgs = {
2+
ipAddress: string
3+
prefix?: string
4+
path?: string
5+
}
6+
7+
/**
8+
* Builds a namespaced storage key for rate-limiting operations.
9+
*
10+
* @param options - Object containing key generation parameters.
11+
* @returns A formatted storage key string.
12+
*/
13+
const createKey = (options: CreateKeyArgs) => {
14+
const args = Object.assign({}, {
15+
prefix: 'ip',
16+
}, options)
17+
18+
if (args.path) {
19+
return `${args.prefix}:${args.path}:${args.ipAddress}`
20+
}
21+
22+
return `${args.prefix}:${args.ipAddress}`
23+
}
24+
25+
export default createKey

src/runtime/server/utils/routes.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { LimitConfiguration, ModuleOptions } from '~/src/type'
2+
3+
/**
4+
* Extract from config route path
5+
* @param routes contains array of route configuration
6+
*/
7+
export function extractRoutePaths(routes: Array<string | { path: string }>): string[] {
8+
return routes.map((route) => {
9+
if (typeof route === 'string') {
10+
return route
11+
}
12+
return route.path
13+
})
14+
}
15+
16+
/**
17+
* This function merges the global `limit` configuration with the per-route
18+
*
19+
* @param path - pathname from event
20+
* @param config - The module configuration containing global and per-route settings.
21+
* @returns The merged rate limit configuration for the route.
22+
*/
23+
export function getRouteLimit(path: string, config: ModuleOptions): LimitConfiguration {
24+
return Object.assign({}, config.limit, config.routes?.find(route =>
25+
typeof route !== 'string' && route.path === path,
26+
) ?? {})
27+
}
28+
29+
/**
30+
* Checks route path has a custom per-route limit defined.
31+
* @param path - pathname to check.
32+
* @param config - The module configuration containing route definitions.
33+
* @returns True if a matching custom route limit exists, false otherwise.
34+
*/
35+
export function hasRouteLimit(path: string, config: ModuleOptions): boolean {
36+
return config.routes?.find(route =>
37+
typeof route !== 'string' && route.path === path,
38+
) !== undefined
39+
}

src/type.d.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { LogEntry } from './runtime/server/types/LogEntry'
2+
3+
export interface LimitConfiguration {
4+
max: number
5+
duration: number
6+
ban: number
7+
}
8+
9+
export interface RouteLimitConfiguration extends Partial<LimitConfiguration> {
10+
path: string
11+
}
12+
13+
export interface ModuleOptions {
14+
limit: LimitConfiguration
15+
delayOnBan: boolean
16+
errorMessage: string
17+
retryAfterHeader: boolean
18+
log?: LogEntry
19+
routes: Array<string | RouteLimitConfiguration>
20+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<template>
2+
<div>withPerRouteLimit</div>
3+
</template>
4+
5+
<script setup></script>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import nuxtApiShield from '../../../src/module'
2+
3+
export default defineNuxtConfig({
4+
modules: [nuxtApiShield],
5+
nitro: {
6+
storage: {
7+
shield: {
8+
// driver: "memory",
9+
driver: 'fs',
10+
base: '_testWithRoutesLimiteShield',
11+
},
12+
},
13+
},
14+
nuxtApiShield: {
15+
limit: {
16+
max: 2,
17+
duration: 3,
18+
ban: 10,
19+
},
20+
errorMessage: 'Wait ! Something went wrong',
21+
retryAfterHeader: true,
22+
log: { path: '', attempts: 0 },
23+
routes: [
24+
'/api/example',
25+
{
26+
path: '/api/example-specific-limit',
27+
max: 1,
28+
duration: 2,
29+
ban: 50,
30+
},
31+
],
32+
},
33+
})

0 commit comments

Comments
 (0)