Skip to content

Commit c535a3f

Browse files
committed
Add per-route limits to the rate limiter
1 parent c716368 commit c535a3f

File tree

4 files changed

+35
-8
lines changed

4 files changed

+35
-8
lines changed

app/routes.js

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,7 @@ export function createRouter() {
5353
// unauthenticated routes
5454
PasswordsRoute(publicRouter);
5555

56-
// Fix for ctx._matchedRoute
57-
// koa-router puts most generic instead of most specific route to the ctx._matchedRoute
58-
// See https://github.com/ZijianHe/koa-router/issues/246
59-
publicRouter.use((ctx, next) => {
60-
ctx.state.matchedRoute = ctx.matched.find((layer) => layer.methods.includes(ctx.method)).path;
61-
return next();
62-
});
56+
publicRouter.use(fixMatchedRouteMiddleware);
6357

6458
// [at least optionally] authenticated routes
6559
publicRouter.use(withJWT);
@@ -100,6 +94,7 @@ export function createRouter() {
10094

10195
{
10296
const adminRouter = new Router();
97+
adminRouter.use(fixMatchedRouteMiddleware);
10398
adminRouter.use(withJWT);
10499
adminRouter.use(rateLimiterMiddleware);
105100
adminRouter.use(withAuthToken);
@@ -112,3 +107,11 @@ export function createRouter() {
112107

113108
return router;
114109
}
110+
111+
// Fix for ctx._matchedRoute
112+
// koa-router puts most generic instead of most specific route to the ctx._matchedRoute
113+
// See https://github.com/ZijianHe/koa-router/issues/246
114+
function fixMatchedRouteMiddleware(ctx, next) {
115+
ctx.state.matchedRoute = ctx.matched.find((layer) => layer.methods.includes(ctx.method)).path;
116+
return next();
117+
}

app/support/rateLimiter.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export async function rateLimiterMiddleware(ctx: Context, next: Next) {
6969
const authTokenType = ctx.state.authJWTPayload?.type || 'anonymous';
7070
const requestId = ctx.state.id;
7171
const requestMethod = ctx.request.method;
72+
const matchedAPIRoute = ctx.state.matchedRoute.replace(/^\/v[^/]+/, '/vN');
7273
const rateLimitConfig = ctx.config.rateLimit;
7374

7475
let realClientId, maskedClientId, rateLimiterConfigByAuthType;
@@ -107,8 +108,11 @@ export async function rateLimiterMiddleware(ctx: Context, next: Next) {
107108

108109
throw new TooManyRequestsException('Slow down');
109110
} else {
111+
const route = `${requestMethod === 'HEAD' ? 'GET' : requestMethod} ${matchedAPIRoute}`;
112+
110113
const { duration, maxRequests } = rateLimiterConfigByAuthType;
111-
const maxRequestsForMethod = maxRequests[requestMethod] || maxRequests.all;
114+
const maxRequestsForMethod =
115+
maxRequests[route] ?? maxRequests[requestMethod] ?? maxRequests.all;
112116
const clientIdWithMethod = `${realClientId}${requestMethod}`;
113117

114118
const limit = await rateLimiter.get({

config/default.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ config.rateLimit = {
478478
maxRequests: {
479479
all: 10, // all methods
480480
GET: 100, // optional
481+
'GET /vN/attachments/:attId/:type': 1000,
481482
},
482483
},
483484
authenticated: {
@@ -486,6 +487,7 @@ config.rateLimit = {
486487
all: 30,
487488
GET: 200,
488489
POST: 60,
490+
'GET /vN/attachments/:attId/:type': 1000,
489491
},
490492
},
491493
maskingKeyRotationInterval: 'P7D', // ISO 8601 duration

test/unit/support/rateLimiter.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { rateLimiterMiddleware, durationToSeconds } from '../../../app/support/r
99
const MAX_ANONYMOUS_REQUESTS = 3;
1010
const MAX_AUTHENTICATED_REQUESTS = 5;
1111
const MAX_AUTHENTICATED_POST_REQUESTS = MAX_AUTHENTICATED_REQUESTS - 1;
12+
const MAX_ROUTE_REQUESTS = 10;
1213
const DURATION = 'PT5S';
1314
const BLOCK_DURATION = 'PT5S';
1415
const BLOCK_MULTIPLIER = 2;
@@ -17,6 +18,7 @@ const baseContext = {
1718
ip: '127.0.0.1',
1819
state: {
1920
authJWTPayload: {},
21+
matchedRoute: '/v1/posts',
2022
},
2123
config: {
2224
rateLimit: {
@@ -26,13 +28,15 @@ const baseContext = {
2628
duration: DURATION,
2729
maxRequests: {
2830
all: MAX_ANONYMOUS_REQUESTS,
31+
'GET /vN/attachments': MAX_ROUTE_REQUESTS,
2932
},
3033
},
3134
authenticated: {
3235
duration: DURATION,
3336
maxRequests: {
3437
all: MAX_AUTHENTICATED_REQUESTS,
3538
POST: MAX_AUTHENTICATED_POST_REQUESTS,
39+
'GET /vN/attachments': MAX_ROUTE_REQUESTS,
3640
},
3741
},
3842
blockDuration: BLOCK_DURATION,
@@ -123,6 +127,20 @@ describe('Rate limiter', () => {
123127
return expect(Promise.all(requests), 'to be fulfilled');
124128
});
125129

130+
it('should block if a specific route limit is exceeded', async () => {
131+
const ctx = merge({}, baseContext, {
132+
config: { rateLimit: { enabled: true } },
133+
state: { matchedRoute: '/v1/attachments' },
134+
});
135+
136+
for (let i = 0; i < MAX_ROUTE_REQUESTS; i++) {
137+
// eslint-disable-next-line no-await-in-loop
138+
await expect(() => rateLimiterMiddleware(ctx, next), 'to be fulfilled');
139+
}
140+
141+
await expect(() => rateLimiterMiddleware(ctx, next), 'to be rejected with', 'Slow down');
142+
});
143+
126144
it('should block for a configurable amount of time', async () => {
127145
const ctx = merge({}, baseContext, {
128146
config: { rateLimit: { enabled: true } },

0 commit comments

Comments
 (0)