From 515ae7ced4b109880306788cb16977c15de92279 Mon Sep 17 00:00:00 2001 From: Yuliia Pominchuk <31064937+yuliia-pominchuk@users.noreply.github.com> Date: Mon, 11 May 2026 11:25:26 +0200 Subject: [PATCH] feat(core): Add IP rate limiting to dynamic credential authentication endpoints (#30199) --- ...credentials-rate-limit.integration.test.ts | 192 ++++++++++++++++++ .../dynamic-credentials.config.ts | 14 ++ .../dynamic-credentials.controller.ts | 34 +++- .../workflow-status.controller.ts | 18 +- 4 files changed, 244 insertions(+), 14 deletions(-) create mode 100644 packages/cli/src/modules/dynamic-credentials.ee/__tests__/dynamic-credentials-rate-limit.integration.test.ts diff --git a/packages/cli/src/modules/dynamic-credentials.ee/__tests__/dynamic-credentials-rate-limit.integration.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/__tests__/dynamic-credentials-rate-limit.integration.test.ts new file mode 100644 index 00000000000..274b3f93353 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/__tests__/dynamic-credentials-rate-limit.integration.test.ts @@ -0,0 +1,192 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return -- jest.mock factory */ +jest.mock('@n8n/backend-common', () => { + const actual = jest.requireActual('@n8n/backend-common'); + return { + ...actual, + inProduction: true, + }; +}); +/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */ + +import { LicenseState } from '@n8n/backend-common'; +import { mockInstance, testDb } from '@n8n/backend-test-utils'; +import { CredentialsRepository } from '@n8n/db'; +import type { ICredentialResolver } from '@n8n/decorators'; +import { Container } from '@n8n/di'; +import { mock } from 'jest-mock-extended'; +import { Cipher } from 'n8n-core'; + +import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee'; +import { OauthService } from '@/oauth/oauth.service'; +import * as utils from '@test-integration/utils'; + +import { DynamicCredentialResolverRepository } from '../database/repositories/credential-resolver.repository'; +import { DynamicCredentialsConfig } from '../dynamic-credentials.config'; +import { DynamicCredentialResolverRegistry } from '../services'; +import type { CredentialResolverWorkflowService } from '../services/credential-resolver-workflow.service'; + +// Enable dynamic credentials feature flag +process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = 'true'; + +// Mock license +const licenseMock = mock(); +licenseMock.isLicensed.mockReturnValue(true); +Container.set(LicenseState, licenseMock); + +const RATE_LIMIT = 5; + +mockInstance(DynamicCredentialsConfig, { + corsOrigin: 'https://app.example.com', + corsAllowCredentials: false, + endpointAuthToken: 'test-static-token', + rateLimitPerMinute: RATE_LIMIT, + rateLimitAuthorizePerMinute: RATE_LIMIT, +}); + +const testServer = utils.setupTestServer({ + endpointGroups: ['credentials'], + enabledFeatures: ['feat:externalSecrets'], + modules: ['dynamic-credentials'], +}); + +let credentialsRepository: CredentialsRepository; +let resolverRepository: DynamicCredentialResolverRepository; +let cipher: Cipher; +let oauthService: OauthService; +let workflowService: CredentialResolverWorkflowService; + +const mockResolver: ICredentialResolver = { + metadata: { + name: 'test-resolver', + description: 'Test resolver for rate limit integration tests', + }, + setSecret: jest.fn().mockResolvedValue(undefined), + getSecret: jest + .fn() + .mockResolvedValue({ token: 'test-token', refreshToken: 'test-refresh-token' }), + deleteSecret: jest.fn().mockResolvedValue(undefined), + validateIdentity: jest.fn().mockResolvedValue(undefined), + validateOptions: jest.fn(), +}; + +beforeAll(async () => { + credentialsRepository = Container.get(CredentialsRepository); + resolverRepository = Container.get(DynamicCredentialResolverRepository); + cipher = Container.get(Cipher); + oauthService = Container.get(OauthService); + + oauthService.generateAOauth2AuthUri = jest + .fn() + .mockResolvedValue('https://oauth.example.com/authorize'); + mockInstance(EnterpriseCredentialsService); + + const { CredentialResolverWorkflowService } = await import( + '../services/credential-resolver-workflow.service' + ); + workflowService = Container.get(CredentialResolverWorkflowService); +}); + +beforeEach(async () => { + await testDb.truncate(['CredentialsEntity', 'DynamicCredentialResolver']); +}); + +function randomId() { + return Math.random().toString(36).substring(2, 15); +} + +function expectTooManyRequests(status: number, body: unknown) { + expect(status).toBe(429); + expect(body).toEqual({ message: 'Too many requests' }); +} + +const commonHeaders = { + Origin: 'https://app.example.com', + Authorization: 'Bearer test-token', + 'X-Authorization': 'Bearer test-static-token', +}; + +async function setupTestData() { + const credential = await credentialsRepository.save( + credentialsRepository.create({ + id: randomId(), + name: 'Test OAuth2 Credential', + type: 'oAuth2Api', + data: cipher.encrypt({ clientId: 'test-client-id' }), + }), + ); + + const resolver = await resolverRepository.save({ + id: randomId(), + name: 'Test Resolver', + type: 'test-resolver', + config: cipher.encrypt(JSON.stringify({ apiKey: 'test-api-key' })), + }); + + const registry = Container.get(DynamicCredentialResolverRegistry); + registry['resolverMap'].set('test-resolver', mockResolver); + + jest.spyOn(workflowService, 'getWorkflowStatus').mockResolvedValue([ + { + credentialId: 'cred-123', + resolverId: 'resolver-123', + credentialName: 'Test Credential', + status: 'configured', + credentialType: 'oAuth2Api', + }, + ]); + + return { + credentialId: credential.id, + resolverId: resolver.id, + workflowId: randomId(), + }; +} + +describe('Dynamic credentials IP rate limiting (production)', () => { + it('should enforce limit on POST /credentials/:id/authorize and not block OPTIONS', async () => { + const { credentialId, resolverId } = await setupTestData(); + const authorizePath = `/credentials/${credentialId}/authorize?resolverId=${resolverId}`; + + for (let i = 0; i < RATE_LIMIT; i++) { + const res = await testServer.authlessAgent.post(authorizePath).set(commonHeaders).send(); + expect(res.status).not.toBe(429); + } + + const blocked = await testServer.authlessAgent.post(authorizePath).set(commonHeaders).send(); + expectTooManyRequests(blocked.status, blocked.body); + + // OPTIONS (preflight) must not be blocked even after limit is exhausted + const optionsRes = await testServer.authlessAgent + .options(authorizePath) + .set('Origin', 'https://app.example.com') + .set('Access-Control-Request-Method', 'POST') + .set('Access-Control-Request-Headers', 'Authorization, Content-Type'); + expect(optionsRes.status).toBe(204); + }); + + it('should enforce limit on DELETE /credentials/:id/revoke', async () => { + const { credentialId, resolverId } = await setupTestData(); + const revokePath = `/credentials/${credentialId}/revoke?resolverId=${resolverId}`; + + for (let i = 0; i < RATE_LIMIT; i++) { + const res = await testServer.authlessAgent.delete(revokePath).set(commonHeaders); + expect(res.status).not.toBe(429); + } + + const blocked = await testServer.authlessAgent.delete(revokePath).set(commonHeaders); + expectTooManyRequests(blocked.status, blocked.body); + }); + + it('should enforce limit on GET /workflows/:workflowId/execution-status', async () => { + const { workflowId } = await setupTestData(); + const statusPath = `/workflows/${workflowId}/execution-status`; + + for (let i = 0; i < RATE_LIMIT; i++) { + const res = await testServer.authlessAgent.get(statusPath).set(commonHeaders); + expect(res.status).not.toBe(429); + } + + const blocked = await testServer.authlessAgent.get(statusPath).set(commonHeaders); + expectTooManyRequests(blocked.status, blocked.body); + }); +}); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.config.ts b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.config.ts index 2275bddc214..c2cc730e3cb 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.config.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.config.ts @@ -23,4 +23,18 @@ export class DynamicCredentialsConfig { */ @Env('N8N_DYNAMIC_CREDENTIALS_ENDPOINT_AUTH_TOKEN') endpointAuthToken: string = ''; + + /** + * Maximum requests per IP per minute to unauthenticated dynamic credential endpoints + * Default: 60 + */ + @Env('N8N_DYNAMIC_CREDENTIALS_RATE_LIMIT_PER_MINUTE') + rateLimitPerMinute: number = 60; + + /** + * Maximum requests per IP per minute to `POST /credentials/:id/authorize`. + * Default: 60. + */ + @Env('N8N_DYNAMIC_CREDENTIALS_AUTHORIZE_RATE_LIMIT_PER_MINUTE') + rateLimitAuthorizePerMinute: number = 60; } diff --git a/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.controller.ts b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.controller.ts index 5ac78aef803..74ad7b677c7 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.controller.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.controller.ts @@ -1,18 +1,24 @@ -import { Delete, Options, Post, RestController } from '@n8n/decorators'; -import { Request, Response } from 'express'; - -import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { CreateCsrfStateData, OauthService } from '@/oauth/oauth.service'; +import { Time } from '@n8n/constants'; import { CredentialsEntity } from '@n8n/db'; -import { DynamicCredentialResolverRepository } from './database/repositories/credential-resolver.repository'; -import { DynamicCredentialResolverRegistry } from './services'; -import { getDynamicCredentialMiddlewares } from './utils'; +import { Delete, Options, Post, RestController } from '@n8n/decorators'; +import { Container } from '@n8n/di'; +import { Request, Response } from 'express'; import { Cipher } from 'n8n-core'; import { jsonParse } from 'n8n-workflow'; + +import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { CreateCsrfStateData, OauthService } from '@/oauth/oauth.service'; + +import { DynamicCredentialResolverRepository } from './database/repositories/credential-resolver.repository'; +import { DynamicCredentialsConfig } from './dynamic-credentials.config'; +import { DynamicCredentialResolverRegistry } from './services'; import { DynamicCredentialCorsService } from './services/dynamic-credential-cors.service'; import { DynamicCredentialWebService } from './services/dynamic-credential-web.service'; +import { getDynamicCredentialMiddlewares } from './utils'; + +const dynamicCredentialsConfig = Container.get(DynamicCredentialsConfig); @RestController('/credentials') export class DynamicCredentialsController { @@ -77,6 +83,10 @@ export class DynamicCredentialsController { @Delete('/:id/revoke', { allowUnauthenticated: true, middlewares: getDynamicCredentialMiddlewares(), + ipRateLimit: { + limit: dynamicCredentialsConfig.rateLimitPerMinute, + windowMs: 1 * Time.minutes.toMilliseconds, + }, }) async revokeCredential(req: Request, res: Response): Promise { this.dynamicCredentialCorsService.applyCorsHeadersIfEnabled(req, res, ['delete', 'options']); @@ -114,6 +124,10 @@ export class DynamicCredentialsController { @Post('/:id/authorize', { allowUnauthenticated: true, middlewares: getDynamicCredentialMiddlewares(), + ipRateLimit: { + limit: dynamicCredentialsConfig.rateLimitAuthorizePerMinute, + windowMs: 1 * Time.minutes.toMilliseconds, + }, }) async authorizeCredential(req: Request, res: Response): Promise { this.dynamicCredentialCorsService.applyCorsHeadersIfEnabled(req, res, ['post', 'options']); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/workflow-status.controller.ts b/packages/cli/src/modules/dynamic-credentials.ee/workflow-status.controller.ts index 958b828751c..52eae731931 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/workflow-status.controller.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/workflow-status.controller.ts @@ -1,14 +1,20 @@ +import { WorkflowExecutionStatus } from '@n8n/api-types'; +import { GlobalConfig } from '@n8n/config'; +import { Time } from '@n8n/constants'; import { Get, Options, RestController } from '@n8n/decorators'; +import { Container } from '@n8n/di'; import { Request, Response } from 'express'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { CredentialResolverWorkflowService } from './services/credential-resolver-workflow.service'; -import { WorkflowExecutionStatus } from '@n8n/api-types'; -import { getDynamicCredentialMiddlewares } from './utils'; import { UrlService } from '@/services/url.service'; -import { GlobalConfig } from '@n8n/config'; + +import { DynamicCredentialsConfig } from './dynamic-credentials.config'; +import { CredentialResolverWorkflowService } from './services/credential-resolver-workflow.service'; import { DynamicCredentialCorsService } from './services/dynamic-credential-cors.service'; import { DynamicCredentialWebService } from './services/dynamic-credential-web.service'; +import { getDynamicCredentialMiddlewares } from './utils'; + +const dynamicCredentialsConfig = Container.get(DynamicCredentialsConfig); @RestController('/workflows') export class WorkflowStatusController { @@ -42,6 +48,10 @@ export class WorkflowStatusController { @Get('/:workflowId/execution-status', { allowUnauthenticated: true, middlewares: getDynamicCredentialMiddlewares(), + ipRateLimit: { + limit: dynamicCredentialsConfig.rateLimitPerMinute, + windowMs: 1 * Time.minutes.toMilliseconds, + }, }) async checkWorkflowForExecution(req: Request, res: Response): Promise { this.dynamicCredentialCorsService.applyCorsHeadersIfEnabled(req, res, ['get', 'options']);