feat(core): Add IP rate limiting to dynamic credential authentication endpoints (#30199)

This commit is contained in:
Yuliia Pominchuk 2026-05-11 11:25:26 +02:00 committed by GitHub
parent 52a4bcb23a
commit 515ae7ced4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 244 additions and 14 deletions

View File

@ -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<LicenseState>();
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);
});
});

View File

@ -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;
}

View File

@ -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<void> {
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<string> {
this.dynamicCredentialCorsService.applyCorsHeadersIfEnabled(req, res, ['post', 'options']);

View File

@ -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<WorkflowExecutionStatus> {
this.dynamicCredentialCorsService.applyCorsHeadersIfEnabled(req, res, ['get', 'options']);