mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
5120d25cbc
commit
f92b4c11d6
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
19
packages/@n8n/decorators/src/controller/rate-limit-access.ts
Normal file
19
packages/@n8n/decorators/src/controller/rate-limit-access.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
169
packages/cli/src/public-api/public-api-rate-limit.service.ts
Normal file
169
packages/cli/src/public-api/public-api-rate-limit.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
29
packages/cli/test/integration/public-api/rate-limit.test.ts
Normal file
29
packages/cli/test/integration/public-api/rate-limit.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user