From f92b4c11d6a74ffa59c142a050ac787a29b83971 Mon Sep 17 00:00:00 2001 From: Uddish Verma Date: Fri, 16 Oct 2026 13:19:34 +0000 Subject: [PATCH] feat(API): Limit Public API requests for each API key (no-changelog) (#41008) Co-authored-by: Claude Opus 5.5 --- .../__tests__/rate-limit-access.test.ts | 45 ++ .../@n8n/decorators/src/controller/index.ts | 2 + .../src/controller/rate-limit-access.ts | 19 + .../@n8n/decorators/src/controller/types.ts | 4 + .../public-api-controller.registry.test.ts | 2 + .../__tests__/public-api-rate-limit.test.ts | 468 ++++++++++++++++++ .../public-api-controller.registry.ts | 5 + .../public-api-rate-limit.service.ts | 169 +++++++ .../cli/src/services/rate-limit.service.ts | 23 + .../integration/public-api/rate-limit.test.ts | 29 ++ 10 files changed, 766 insertions(+) create mode 100644 packages/@n8n/decorators/src/controller/__tests__/rate-limit-access.test.ts create mode 100644 packages/@n8n/decorators/src/controller/rate-limit-access.ts create mode 100644 packages/cli/src/public-api/__tests__/public-api-rate-limit.test.ts create mode 100644 packages/cli/src/public-api/public-api-rate-limit.service.ts create mode 100644 packages/cli/test/integration/public-api/rate-limit.test.ts diff --git a/packages/@n8n/decorators/src/controller/__tests__/rate-limit-access.test.ts b/packages/@n8n/decorators/src/controller/__tests__/rate-limit-access.test.ts new file mode 100644 index 00000000000..aee0b9c428d --- /dev/null +++ b/packages/@n8n/decorators/src/controller/__tests__/rate-limit-access.test.ts @@ -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(); + }); +}); diff --git a/packages/@n8n/decorators/src/controller/index.ts b/packages/@n8n/decorators/src/controller/index.ts index e29cff3c8a6..06ca4a532da 100644 --- a/packages/@n8n/decorators/src/controller/index.ts +++ b/packages/@n8n/decorators/src/controller/index.ts @@ -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, diff --git a/packages/@n8n/decorators/src/controller/rate-limit-access.ts b/packages/@n8n/decorators/src/controller/rate-limit-access.ts new file mode 100644 index 00000000000..9ebba1582d1 --- /dev/null +++ b/packages/@n8n/decorators/src/controller/rate-limit-access.ts @@ -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; + }; diff --git a/packages/@n8n/decorators/src/controller/types.ts b/packages/@n8n/decorators/src/controller/types.ts index a09be58c356..019db1a13b3 100644 --- a/packages/@n8n/decorators/src/controller/types.ts +++ b/packages/@n8n/decorators/src/controller/types.ts @@ -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; diff --git a/packages/cli/src/public-api/__tests__/public-api-controller.registry.test.ts b/packages/cli/src/public-api/__tests__/public-api-controller.registry.test.ts index 15b2bc522dc..754f092a396 100644 --- a/packages/cli/src/public-api/__tests__/public-api-controller.registry.test.ts +++ b/packages/cli/src/public-api/__tests__/public-api-controller.registry.test.ts @@ -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({ createRouteLimiters: () => [] }), idempotencyKeyService, logger, ).activate(router, 'v1'); diff --git a/packages/cli/src/public-api/__tests__/public-api-rate-limit.test.ts b/packages/cli/src/public-api/__tests__/public-api-rate-limit.test.ts new file mode 100644 index 00000000000..8f75d7e08f8 --- /dev/null +++ b/packages/cli/src/public-api/__tests__/public-api-rate-limit.test.ts @@ -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(); + const lastActiveAtService = mock(); + const authenticatedUser = mock({ id: 'user-1' }); + + function activate(rateLimit: Partial = {}): express.Express { + const globalConfig = mock({ + 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(), + new PublicApiRateLimitService(globalConfig, new RateLimitService()), + mock(), + mock(), + ).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 { + 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({ + 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({ 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); + }); +}); diff --git a/packages/cli/src/public-api/public-api-controller.registry.ts b/packages/cli/src/public-api/public-api-controller.registry.ts index 90815b5a384..4420d08ee61 100644 --- a/packages/cli/src/public-api/public-api-controller.registry.ts +++ b/packages/cli/src/public-api/public-api-controller.registry.ts @@ -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. diff --git a/packages/cli/src/public-api/public-api-rate-limit.service.ts b/packages/cli/src/public-api/public-api-rate-limit.service.ts new file mode 100644 index 00000000000..a6d6bb99b75 --- /dev/null +++ b/packages/cli/src/public-api/public-api-rate-limit.service.ts @@ -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 = 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 = { + 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 }; + } +} diff --git a/packages/cli/src/services/rate-limit.service.ts b/packages/cli/src/services/rate-limit.service.ts index 1bb749b6271..62be93553ea 100644 --- a/packages/cli/src/services/rate-limit.service.ts +++ b/packages/cli/src/services/rate-limit.service.ts @@ -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, + { 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'; diff --git a/packages/cli/test/integration/public-api/rate-limit.test.ts b/packages/cli/test/integration/public-api/rate-limit.test.ts new file mode 100644 index 00000000000..00ceb07fd13 --- /dev/null +++ b/packages/cli/test/integration/public-api/rate-limit.test.ts @@ -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'); + }); +});