feat(API): Limit Public API requests for each API key (no-changelog) (#41008)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Uddish Verma 2026-10-16 13:19:34 +00:00 committed by GitHub
parent 5120d25cbc
commit f92b4c11d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 766 additions and 0 deletions

View File

@ -0,0 +1,45 @@
import { Container } from '@n8n/di';
import { ControllerRegistryMetadata } from '../controller-registry-metadata';
import { RateLimitAccess } from '../rate-limit-access';
import { Post } from '../route';
import type { Controller } from '../types';
describe('@RateLimitAccess Decorator', () => {
let controllerRegistryMetadata: ControllerRegistryMetadata;
beforeEach(() => {
controllerRegistryMetadata = new ControllerRegistryMetadata();
Container.set(ControllerRegistryMetadata, controllerRegistryMetadata);
});
it('should set rateLimitAccess on route metadata', () => {
class TestController {
@Post('/test')
@RateLimitAccess('read')
testMethod() {}
}
const routeMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'testMethod',
);
expect(routeMetadata.rateLimitAccess).toBe('read');
expect(routeMetadata.method).toBe('post');
});
it('should leave rateLimitAccess undefined on a route without the decorator', () => {
class TestController {
@Post('/test')
testMethod() {}
}
const routeMetadata = controllerRegistryMetadata.getRouteMetadata(
TestController as Controller,
'testMethod',
);
expect(routeMetadata.rateLimitAccess).toBeUndefined();
});
});

View File

@ -13,6 +13,7 @@ export { Middleware } from './middleware';
export { ControllerRegistryMetadata } from './controller-registry-metadata';
export { Licensed } from './licensed';
export { RequiresUserQuota } from './requires-user-quota';
export { RateLimitAccess } from './rate-limit-access';
export { Deprecated } from './deprecated';
export { GlobalScope, ProjectScope } from './scoped';
export type {
@ -28,6 +29,7 @@ export type {
ErrorResponse,
HandlerName,
Method,
RateLimitAccessName,
MultipartUploadLimits,
RequestBodyMedia,
RequestBodyMediaOptions,

View File

@ -0,0 +1,19 @@
import { Container } from '@n8n/di';
import { ControllerRegistryMetadata } from './controller-registry-metadata';
import type { Controller, RateLimitAccessName } from './types';
/**
* Sets whether the Public API rate limiter treats this route as a read or a write. Use it when
* the HTTP method does not match what the route does, for example a `POST` that only reads.
* Without it, `GET` and `HEAD` are reads and all other methods are writes.
*/
export const RateLimitAccess =
(access: RateLimitAccessName): MethodDecorator =>
(target, handlerName) => {
const routeMetadata = Container.get(ControllerRegistryMetadata).getRouteMetadata(
target.constructor as Controller,
String(handlerName),
);
routeMetadata.rateLimitAccess = access;
};

View File

@ -43,6 +43,8 @@ export interface ErrorResponse {
export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'options';
export type RateLimitAccessName = 'read' | 'write';
/** Multer limits for a `multipart/form-data` `@Body`. Every key matches multer's own `limits` option. */
export interface MultipartUploadLimits {
fieldNameSize?: number;
@ -115,6 +117,8 @@ export interface RouteMetadata {
licenseFeature?: BooleanLicenseFeature;
/** Public API only: gate the route on the instance being within its licensed users quota. */
requiresUserQuota?: boolean;
/** Public API only: counts the route as a read or a write, in place of what the HTTP method implies. */
rateLimitAccess?: RateLimitAccessName;
accessScope?: AccessScope;
apiKeyScope?: ApiKeyScopeRequirement;
responseDto?: ResponseDtoClass;

View File

@ -35,6 +35,7 @@ import {
} from '@/public-api/__tests__/public-api-controller-test-utils';
import type { IdempotencyKeyService } from '@/public-api/idempotency-key.service';
import { PublicApiControllerRegistry } from '@/public-api/public-api-controller.registry';
import type { PublicApiRateLimitService } from '@/public-api/public-api-rate-limit.service';
import type { LastActiveAtService } from '@/services/last-active-at.service';
vi.mock('@/permissions.ee/check-access', () => ({ userHasScopes: vi.fn() }));
@ -61,6 +62,7 @@ describe('PublicApiControllerRegistry', () => {
authStrategyRegistry,
lastActiveAtService,
eventService,
mock<PublicApiRateLimitService>({ createRouteLimiters: () => [] }),
idempotencyKeyService,
logger,
).activate(router, 'v1');

View File

@ -0,0 +1,468 @@
import type { Logger } from '@n8n/backend-common';
import type { EventService } from '@n8n/backend-services';
import type { AuthStrategyRegistry } from '@n8n/backend-services/auth';
import type { GlobalConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import type { AuthenticatedRequest, User } from '@n8n/db';
import {
ApiResponse,
ControllerRegistryMetadata,
Get,
Param,
Post,
RateLimitAccess,
} from '@n8n/decorators';
import type { Controller } from '@n8n/decorators';
import { Container, Service } from '@n8n/di';
import express from 'express';
import type { Request, RequestHandler, Response } from 'express';
import { request as httpRequest } from 'node:http';
import type { AddressInfo } from 'node:net';
import request from 'supertest';
import { mock } from 'vitest-mock-extended';
import { markPublicApiController } from '@/public-api/__tests__/public-api-controller-test-utils';
import type { IdempotencyKeyService } from '@/public-api/idempotency-key.service';
import { PublicApiControllerRegistry } from '@/public-api/public-api-controller.registry';
import { PublicApiRateLimitService } from '@/public-api/public-api-rate-limit.service';
import { RateLimitService } from '@/services/rate-limit.service';
import type { LastActiveAtService } from '@/services/last-active-at.service';
type RateLimitConfig = GlobalConfig['publicApi']['rateLimit'];
const API_KEY_HEADER = 'x-test-api-key-id';
describe('Public API rate limiting', () => {
let slowHandlerRuns = 0;
let finishSlowHandler: (value?: unknown) => void = () => {};
const authStrategyRegistry = mock<AuthStrategyRegistry>();
const lastActiveAtService = mock<LastActiveAtService>();
const authenticatedUser = mock<User>({ id: 'user-1' });
function activate(rateLimit: Partial<RateLimitConfig> = {}): express.Express {
const globalConfig = mock<GlobalConfig>({
publicApi: {
rateLimit: {
enabled: true,
globalReadLimit: 100,
globalReadWindowSeconds: 60,
globalWriteLimit: 100,
globalWriteWindowSeconds: 60,
endpointReadLimit: 100,
endpointReadWindowSeconds: 60,
endpointWriteLimit: 100,
endpointWriteWindowSeconds: 60,
...rateLimit,
},
},
});
const app = express();
const router = express.Router({ mergeParams: true });
new PublicApiControllerRegistry(
Container.get(ControllerRegistryMetadata),
authStrategyRegistry,
lastActiveAtService,
mock<EventService>(),
new PublicApiRateLimitService(globalConfig, new RateLimitService()),
mock<IdempotencyKeyService>(),
mock<Logger>(),
).activate(router, 'v1');
app.use('/api/v1', router);
return app;
}
function registerWidgets() {
@Service()
class WidgetsPublicController {
@Get('/')
@ApiResponse(200)
list() {
return { ok: true };
}
@Get('/other')
@ApiResponse(200)
other() {
return { ok: true };
}
@Get('/:id')
@ApiResponse(200)
get(_req: unknown, _res: unknown, @Param('id') id: string) {
return { id };
}
@Post('/')
@ApiResponse(200)
create() {
return { ok: true };
}
@Post('/slow')
@ApiResponse(200)
async slow() {
slowHandlerRuns++;
await new Promise((resolve) => {
finishSlowHandler = resolve;
});
return { ok: true };
}
@Post('/search')
@ApiResponse(200)
@RateLimitAccess('read')
search() {
return { ok: true };
}
}
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
}
async function send(
app: express.Express,
method: 'get' | 'head' | 'post',
path: string,
apiKeyId: string | null = 'key-1',
) {
const req = request(app)[method](`/api/v1${path}`);
return await (apiKeyId === null ? req : req.set(API_KEY_HEADER, apiKeyId));
}
async function sendTimes(
app: express.Express,
times: number,
method: 'get' | 'post',
path: string,
): Promise<number[]> {
const statuses: number[] = [];
for (let i = 0; i < times; i++) {
statuses.push((await send(app, method, path)).status);
}
return statuses;
}
beforeEach(() => {
vi.resetAllMocks();
slowHandlerRuns = 0;
finishSlowHandler = () => {};
authStrategyRegistry.authenticate.mockImplementation(async (req: AuthenticatedRequest) => {
req.user = authenticatedUser;
const apiKeyId = req.headers[API_KEY_HEADER];
req.tokenGrant = {
scopes: [],
subject: authenticatedUser,
...(typeof apiKeyId === 'string' && { apiKeyId }),
};
return true;
});
lastActiveAtService.updateLastActiveIfStale.mockResolvedValue(undefined);
Container.set(ControllerRegistryMetadata, new ControllerRegistryMetadata());
registerWidgets();
});
it('does not limit requests when rate limiting is off', async () => {
const app = activate({ enabled: false, globalReadLimit: 1, endpointReadLimit: 1 });
expect(await sendTimes(app, 3, 'get', '/widgets')).toEqual([200, 200, 200]);
});
it('returns 429 with rate-limit headers when the all-endpoints read limit is reached', async () => {
const app = activate({ globalReadLimit: 2 });
await send(app, 'get', '/widgets');
await send(app, 'get', '/widgets/other');
const response = await send(app, 'get', '/widgets/1');
expect(response.status).toBe(429);
expect(response.body).toEqual({ message: 'Too many requests' });
expect(response.headers['retry-after']).toBeDefined();
expect(response.headers['x-ratelimit-limit']).toBe('2');
expect(response.headers['x-ratelimit-remaining']).toBe('0');
expect(response.headers['x-ratelimit-reset']).toBeDefined();
});
it('limits one endpoint without blocking the other endpoints', async () => {
const app = activate({ endpointReadLimit: 2 });
expect(await sendTimes(app, 2, 'get', '/widgets')).toEqual([200, 200]);
const blocked = await send(app, 'get', '/widgets');
expect(blocked.status).toBe(429);
expect(blocked.headers['x-ratelimit-limit']).toBe('2');
expect(blocked.headers['x-ratelimit-remaining']).toBe('0');
expect(blocked.headers['retry-after']).toBeDefined();
expect((await send(app, 'get', '/widgets/other')).status).toBe(200);
});
it('counts two path values of one route template as one endpoint', async () => {
const app = activate({ endpointReadLimit: 2 });
await send(app, 'get', '/widgets/1');
await send(app, 'get', '/widgets/2');
expect((await send(app, 'get', '/widgets/3')).status).toBe(429);
});
it('counts reads and writes apart', async () => {
const app = activate({ globalReadLimit: 1, globalWriteLimit: 1 });
expect(await sendTimes(app, 1, 'get', '/widgets')).toEqual([200]);
expect(await sendTimes(app, 1, 'post', '/widgets')).toEqual([200]);
expect((await send(app, 'get', '/widgets')).status).toBe(429);
expect((await send(app, 'post', '/widgets')).status).toBe(429);
});
it('counts each API key apart', async () => {
const app = activate({ globalReadLimit: 1 });
expect((await send(app, 'get', '/widgets', 'key-1')).status).toBe(200);
expect((await send(app, 'get', '/widgets', 'key-2')).status).toBe(200);
expect((await send(app, 'get', '/widgets', 'key-1')).status).toBe(429);
});
it('does not limit a request without an API key ID', async () => {
const app = activate({ globalReadLimit: 1, endpointReadLimit: 1 });
for (let i = 0; i < 3; i++) {
const response = await send(app, 'get', '/widgets', null);
expect(response.status).toBe(200);
expect(response.headers['x-ratelimit-limit']).toBeUndefined();
}
});
it('turns off a layer when its limit is 0', async () => {
const app = activate({ globalReadLimit: 0, endpointReadLimit: 2 });
expect(await sendTimes(app, 2, 'get', '/widgets')).toEqual([200, 200]);
expect((await send(app, 'get', '/widgets')).status).toBe(429);
expect(await sendTimes(app, 2, 'get', '/widgets/other')).toEqual([200, 200]);
});
it.each([
{ layer: 'per-endpoint', globalReadLimit: 0, endpointReadLimit: 3 },
{ layer: 'all-endpoints', globalReadLimit: 3, endpointReadLimit: 0 },
])(
'reports the $layer limit in the headers when it is the only active limit',
async ({ globalReadLimit, endpointReadLimit }) => {
const app = activate({ globalReadLimit, endpointReadLimit });
const response = await send(app, 'get', '/widgets');
expect(response.status).toBe(200);
expect(response.headers['x-ratelimit-limit']).toBe('3');
expect(response.headers['x-ratelimit-remaining']).toBe('2');
},
);
it('does not count a request that the per-endpoint limit blocks toward the all-endpoints limit', async () => {
const app = activate({ globalReadLimit: 3, endpointReadLimit: 1 });
await send(app, 'get', '/widgets');
expect((await send(app, 'get', '/widgets')).status).toBe(429);
expect((await send(app, 'get', '/widgets')).status).toBe(429);
expect(await sendTimes(app, 1, 'get', '/widgets/other')).toEqual([200]);
expect(await sendTimes(app, 1, 'get', '/widgets/1')).toEqual([200]);
});
it('does not count a request that the all-endpoints limit blocks toward the per-endpoint limit', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
try {
const app = activate({
globalReadLimit: 3,
globalReadWindowSeconds: 5,
endpointReadLimit: 5,
endpointReadWindowSeconds: 60,
});
expect(await sendTimes(app, 3, 'get', '/widgets')).toEqual([200, 200, 200]);
expect((await send(app, 'get', '/widgets')).status).toBe(429);
expect((await send(app, 'get', '/widgets')).status).toBe(429);
vi.advanceTimersByTime(6 * Time.seconds.toMilliseconds);
expect(await sendTimes(app, 2, 'get', '/widgets')).toEqual([200, 200]);
} finally {
vi.useRealTimers();
}
});
it('reports the per-endpoint limit when it has fewer requests left', async () => {
const app = activate({ globalReadLimit: 10, endpointReadLimit: 2 });
const response = await send(app, 'get', '/widgets');
expect(response.status).toBe(200);
expect(response.headers['x-ratelimit-limit']).toBe('2');
expect(response.headers['x-ratelimit-remaining']).toBe('1');
expect(response.headers['x-ratelimit-reset']).toBeDefined();
});
it('reports the all-endpoints limit when it has fewer requests left', async () => {
const app = activate({ globalReadLimit: 5, endpointReadLimit: 10 });
expect(await sendTimes(app, 3, 'get', '/widgets/other')).toEqual([200, 200, 200]);
const response = await send(app, 'get', '/widgets');
expect(response.status).toBe(200);
expect(response.headers['x-ratelimit-limit']).toBe('5');
expect(response.headers['x-ratelimit-remaining']).toBe('1');
});
it('takes back the count of a blocked request before its response ends', async () => {
// The response never ends, so this fails if the count comes back only when it does. Until
// then, parallel requests would see the blocked request in the all-endpoints count.
const service = new PublicApiRateLimitService(
mock<GlobalConfig>({
publicApi: {
rateLimit: {
enabled: true,
globalReadLimit: 2,
globalReadWindowSeconds: 60,
endpointReadLimit: 1,
endpointReadWindowSeconds: 60,
},
},
}),
new RateLimitService(),
);
const widgets = service.createRouteLimiters('get');
const others = service.createRouteLimiters('get');
const runRoute = async (handlers: RequestHandler[]) => {
const req = { tokenGrant: { apiKeyId: 'key-1' }, ip: '127.0.0.1', headers: {} };
const res = mock<Response>({ headersSent: false, writableEnded: false });
res.status.mockReturnValue(res);
for (const handler of handlers) {
let passed = false;
await handler(req as unknown as Request, res, () => {
passed = true;
});
if (!passed) return res.status.mock.calls[0]?.[0];
}
return 200;
};
expect(await runRoute(widgets)).toBe(200);
expect(await runRoute(widgets)).toBe(429);
expect(await runRoute(others)).toBe(200);
});
it('still counts a request whose client disconnects before the response', async () => {
const app = activate({ globalWriteLimit: 1 });
const server = app.listen(0);
const { port } = server.address() as AddressInfo;
const serverSawClose = new Promise((resolve) =>
server.once('connection', (socket) => socket.once('close', resolve)),
);
try {
const req = httpRequest({
port,
method: 'POST',
path: '/api/v1/widgets/slow',
headers: { [API_KEY_HEADER]: 'key-1' },
});
req.on('error', () => {});
req.end();
await vi.waitFor(() => expect(slowHandlerRuns).toBe(1));
req.destroy();
await serverSawClose;
finishSlowHandler();
expect((await send(app, 'post', '/widgets')).status).toBe(429);
} finally {
finishSlowHandler();
server.close();
}
});
describe('when both limits run out at the same time', () => {
const now = new Date('2026-01-01T00:00:00Z');
const nowSeconds = now.getTime() / Time.seconds.toMilliseconds;
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(now);
});
afterEach(() => {
vi.useRealTimers();
});
it('reports the limit that resets later', async () => {
const app = activate({
globalReadLimit: 1,
globalReadWindowSeconds: 60,
endpointReadLimit: 1,
endpointReadWindowSeconds: 10,
});
const response = await send(app, 'get', '/widgets');
expect(response.status).toBe(200);
expect(response.headers['x-ratelimit-remaining']).toBe('0');
expect(response.headers['x-ratelimit-reset']).toBe(String(nowSeconds + 60));
});
it('tells a blocked client to wait until both limits reset', async () => {
const app = activate({
globalReadLimit: 1,
globalReadWindowSeconds: 60,
endpointReadLimit: 1,
endpointReadWindowSeconds: 10,
});
expect(await sendTimes(app, 1, 'get', '/widgets')).toEqual([200]);
const blocked = await send(app, 'get', '/widgets');
expect(blocked.status).toBe(429);
expect(blocked.headers['retry-after']).toBe('60');
expect(blocked.headers['x-ratelimit-limit']).toBe('1');
expect(blocked.headers['x-ratelimit-reset']).toBe(String(nowSeconds + 60));
});
it('reports only the exceeded limit when the other limit is exactly used up', async () => {
const app = activate({
globalReadLimit: 2,
globalReadWindowSeconds: 60,
endpointReadLimit: 1,
endpointReadWindowSeconds: 10,
});
expect(await sendTimes(app, 1, 'get', '/widgets')).toEqual([200]);
const blocked = await send(app, 'get', '/widgets');
expect(blocked.status).toBe(429);
expect(blocked.headers['retry-after']).toBe('10');
expect(blocked.headers['x-ratelimit-limit']).toBe('1');
vi.advanceTimersByTime(11 * Time.seconds.toMilliseconds);
expect(await sendTimes(app, 1, 'get', '/widgets')).toEqual([200]);
});
});
it('does not count a HEAD request, which the registry rejects with 405', async () => {
const app = activate({ globalReadLimit: 1, endpointReadLimit: 1 });
expect((await send(app, 'head', '/widgets')).status).toBe(405);
expect((await send(app, 'head', '/widgets')).status).toBe(405);
expect((await send(app, 'get', '/widgets')).status).toBe(200);
});
it('uses the access from @RateLimitAccess over the HTTP method', async () => {
const app = activate({ globalReadLimit: 1, globalWriteLimit: 100 });
expect(await sendTimes(app, 1, 'post', '/widgets/search')).toEqual([200]);
expect((await send(app, 'get', '/widgets')).status).toBe(429);
expect((await send(app, 'post', '/widgets')).status).toBe(200);
});
});

View File

@ -42,6 +42,7 @@ import {
resolveSuccessStatus,
} from '@/public-api/public-api-route-resolver';
import { formatValidationError } from '@/public-api/public-api-validation-error';
import { PublicApiRateLimitService } from '@/public-api/public-api-rate-limit.service';
import { sendPublicApiErrorResponse } from '@/public-api/v1/public-api-error-response';
import { LastActiveAtService } from '@/services/last-active-at.service';
@ -86,6 +87,7 @@ export class PublicApiControllerRegistry {
private readonly authStrategyRegistry: AuthStrategyRegistry,
private readonly lastActiveAtService: LastActiveAtService,
private readonly eventService: EventService,
private readonly rateLimitService: PublicApiRateLimitService,
private readonly idempotencyKeyService: IdempotencyKeyService,
private readonly logger: Logger,
) {}
@ -237,6 +239,9 @@ export class PublicApiControllerRegistry {
}
middlewares.push(this.createAuthMiddleware(apiVersion, prefix));
middlewares.push(
...this.rateLimitService.createRouteLimiters(route.method, route.rateLimitAccess),
);
// Path param validation must run before the scope checks, so that a malformed param always
// returns 400, rather than 404 or 403 depending on the caller's access.

View File

@ -0,0 +1,169 @@
import { GlobalConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import type { Method, RateLimitAccessName } from '@n8n/decorators';
import { Service } from '@n8n/di';
import type { RequestHandler, Response } from 'express';
import { MemoryStore } from 'express-rate-limit';
import type { AugmentedRequest, RateLimitInfo } from 'express-rate-limit';
import { RateLimitService } from '@/services/rate-limit.service';
type RequestLimit = { maxRequests: number; windowSeconds: number };
type AccessRequestLimits = { perEndpoint: RequestLimit; allEndpoints: RequestLimit };
type CountingLimiter = {
middleware: RequestHandler;
store: MemoryStore;
requestPropertyName: string;
};
const ENDPOINT_LIMIT = 'endpointRateLimit';
const ALL_ENDPOINTS_LIMIT = 'allEndpointsRateLimit';
const resetMs = (limit: RateLimitInfo) => limit.resetTime?.getTime() ?? 0;
const setRateLimitHeaders = (res: Response, limit: RateLimitInfo) => {
res.setHeader('X-RateLimit-Limit', limit.limit.toString());
res.setHeader('X-RateLimit-Remaining', limit.remaining.toString());
if (limit.resetTime) {
const resetSeconds = Math.ceil(resetMs(limit) / Time.seconds.toMilliseconds);
res.setHeader('X-RateLimit-Reset', resetSeconds.toString());
}
};
const READ_METHODS: ReadonlySet<Method> = new Set(['get', 'head']);
const resolveAccess = (
method: Method,
accessFromDecorator?: RateLimitAccessName,
): RateLimitAccessName => accessFromDecorator ?? (READ_METHODS.has(method) ? 'read' : 'write');
@Service()
export class PublicApiRateLimitService {
private readonly allEndpointsLimiterCache = new Map<
RateLimitAccessName,
CountingLimiter | undefined
>();
constructor(
private readonly globalConfig: GlobalConfig,
private readonly rateLimitService: RateLimitService,
) {}
private get config() {
return this.globalConfig.publicApi.rateLimit;
}
/** Call once for each route, when the route is registered. */
createRouteLimiters(method: Method, accessFromDecorator?: RateLimitAccessName): RequestHandler[] {
if (!this.config.enabled) return [];
const access = resolveAccess(method, accessFromDecorator);
const limiters = [
this.createPerEndpointLimiter(access),
this.getAllEndpointsLimiter(access),
].filter((limiter) => limiter !== undefined);
if (limiters.length === 0) return [];
return [...limiters.map((limiter) => limiter.middleware), this.enforceLimits(limiters)];
}
/**
* Runs after the limiters, which only count. Sends a 429 when the request is over any limit.
* Otherwise sets the headers from the limit with the fewest requests left.
*/
private enforceLimits(limiters: CountingLimiter[]): RequestHandler {
return async (req: AugmentedRequest, res, next) => {
const counted = limiters.flatMap(({ store, requestPropertyName }) => {
const limit: RateLimitInfo | undefined = req[requestPropertyName];
return limit ? [{ store, limit }] : [];
});
const limits = counted.map(({ limit }) => limit);
const exceeded = limits.filter((limit) => limit.used > limit.limit);
if (exceeded.length > 0) {
// A blocked request uses no quota. Take it back now, not when the response ends, so a
// parallel request never sees it.
await Promise.all(
counted.map(async ({ store, limit }) => await store.decrement(limit.key)),
);
// The client can retry only when every exceeded limit has reset.
const [lastToReset] = exceeded.sort((a, b) => resetMs(b) - resetMs(a));
const retryAfterMs = resetMs(lastToReset) - Date.now();
setRateLimitHeaders(res, lastToReset);
res.setHeader(
'Retry-After',
Math.ceil(Math.max(0, retryAfterMs) / Time.seconds.toMilliseconds).toString(),
);
res.status(429).json({ message: 'Too many requests' });
return;
}
// On a tie, the later reset wins, so the reset time is never too early.
const [fewestLeft] = limits.sort(
(a, b) => a.remaining - b.remaining || resetMs(b) - resetMs(a),
);
if (fewestLeft) setRateLimitHeaders(res, fewestLeft);
next();
};
}
private createPerEndpointLimiter(access: RateLimitAccessName) {
return this.createRateLimiter(this.getLimitsFromConfig(access).perEndpoint, ENDPOINT_LIMIT);
}
private getAllEndpointsLimiter(access: RateLimitAccessName) {
if (!this.allEndpointsLimiterCache.has(access)) {
this.allEndpointsLimiterCache.set(
access,
this.createRateLimiter(this.getLimitsFromConfig(access).allEndpoints, ALL_ENDPOINTS_LIMIT),
);
}
return this.allEndpointsLimiterCache.get(access);
}
private getLimitsFromConfig(access: RateLimitAccessName): AccessRequestLimits {
const { config } = this;
const limits: Record<RateLimitAccessName, AccessRequestLimits> = {
read: {
perEndpoint: {
maxRequests: config.endpointReadLimit,
windowSeconds: config.endpointReadWindowSeconds,
},
allEndpoints: {
maxRequests: config.globalReadLimit,
windowSeconds: config.globalReadWindowSeconds,
},
},
write: {
perEndpoint: {
maxRequests: config.endpointWriteLimit,
windowSeconds: config.endpointWriteWindowSeconds,
},
allEndpoints: {
maxRequests: config.globalWriteLimit,
windowSeconds: config.globalWriteWindowSeconds,
},
},
};
return limits[access];
}
private createRateLimiter(
{ maxRequests, windowSeconds }: RequestLimit,
requestPropertyName: string,
): CountingLimiter | undefined {
if (maxRequests === 0) return undefined;
const limits = { limit: maxRequests, windowMs: windowSeconds * Time.seconds.toMilliseconds };
const store = new MemoryStore();
const middleware = this.rateLimitService.createApiKeyRateLimitMiddleware(limits, {
requestPropertyName,
store,
});
return { middleware, store, requestPropertyName };
}
}

View File

@ -6,6 +6,7 @@ import { BodyKeyedRateLimiterConfig } from '@n8n/decorators';
import { Service } from '@n8n/di';
import type { Request, RequestHandler } from 'express';
import { rateLimit as expressRateLimit } from 'express-rate-limit';
import type { Store } from 'express-rate-limit';
import assert from 'node:assert';
import type { ZodTypeAny } from 'zod';
@ -74,6 +75,28 @@ export class RateLimitService {
});
}
/**
* Counts requests for each API key and never blocks. Requests without an API key are skipped.
* The counts are stored on the request under `requestPropertyName`.
*/
createApiKeyRateLimitMiddleware(
limits: Required<RateLimiterLimits>,
{ requestPropertyName, store }: { requestPropertyName: string; store: Store },
): RequestHandler {
return expressRateLimit({
limit: limits.limit,
windowMs: limits.windowMs,
legacyHeaders: false,
standardHeaders: false,
requestPropertyName,
store,
handler: (_req, _res, next) => next(),
passOnStoreError: true,
skip: (req: AuthenticatedRequest): boolean => req.tokenGrant?.apiKeyId === undefined,
keyGenerator: (req: AuthenticatedRequest) => `apiKey:${req.tokenGrant?.apiKeyId}`,
});
}
private extractBodyIdentifier(body: unknown, fieldName: string, fieldSchema: ZodTypeAny): string {
if (!body || typeof body !== 'object') {
return 'skip:empty-body';

View File

@ -0,0 +1,29 @@
import { GlobalConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import { createOwnerWithApiKey } from '../shared/db/users';
import * as utils from '../shared/utils/';
Object.assign(Container.get(GlobalConfig).publicApi.rateLimit, {
enabled: true,
globalReadLimit: 2,
});
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
describe('Public API rate limiting', () => {
test('should return 429 when an API key goes over the all-endpoints read limit', async () => {
const owner = await createOwnerWithApiKey();
const agent = testServer.publicApiAgentFor(owner);
const [response1, response2] = await Promise.all([agent.get('/tags'), agent.get('/tags')]);
expect(response1.statusCode).toBe(200);
expect(response2.statusCode).toBe(200);
const rateLimitedResponse = await agent.get('/tags');
expect(rateLimitedResponse.statusCode).toBe(429);
expect(rateLimitedResponse.headers['retry-after']).toBeDefined();
expect(rateLimitedResponse.headers['x-ratelimit-limit']).toBe('2');
});
});