chore(core): Introduce CORS settings for dynamic credential interface (#24324)

This commit is contained in:
Andreas Fitzek
2026-01-14 17:17:58 +00:00
committed by GitHub
parent 159c110a18
commit ee9c1d77da
13 changed files with 960 additions and 7 deletions
@@ -6,4 +6,11 @@ export { Middleware } from './middleware';
export { ControllerRegistryMetadata } from './controller-registry-metadata';
export { Licensed } from './licensed';
export { GlobalScope, ProjectScope } from './scoped';
export type { AccessScope, Controller, RateLimit, StaticRouterMetadata } from './types';
export type {
AccessScope,
Controller,
CorsOptions,
Method,
RateLimit,
StaticRouterMetadata,
} from './types';
@@ -2,7 +2,7 @@ import { Container } from '@n8n/di';
import type { RequestHandler } from 'express';
import { ControllerRegistryMetadata } from './controller-registry-metadata';
import type { Controller, Method, RateLimit } from './types';
import type { Controller, CorsOptions, Method, RateLimit } from './types';
interface RouteOptions {
middlewares?: RequestHandler[];
@@ -16,6 +16,8 @@ interface RouteOptions {
rateLimit?: boolean | RateLimit;
/** When this flag is set to true, the endpoint is protected by API key */
apiKeyAuth?: boolean;
/** CORS options for the route, this does not handle preflight requests */
cors?: Partial<CorsOptions> | true;
}
const RouteFactory =
@@ -35,6 +37,7 @@ const RouteFactory =
routeMetadata.allowSkipMFA = options.allowSkipMFA ?? false;
routeMetadata.apiKeyAuth = options.apiKeyAuth ?? false;
routeMetadata.rateLimit = options.rateLimit;
routeMetadata.cors = options.cors;
};
export const Get = RouteFactory('get');
@@ -7,6 +7,14 @@ export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head' | 'opt
export type Arg = { type: 'body' | 'query' } | { type: 'param'; key: string };
export interface CorsOptions {
allowedOrigins: string[];
allowedMethods: Method[];
allowedHeaders: string[];
allowCredentials?: boolean;
maxAge?: number;
}
export interface RateLimit {
/**
* The maximum number of requests to allow during the `window` before rate limiting the client.
@@ -36,6 +44,7 @@ export interface RouteMetadata {
allowSkipPreviewAuth: boolean;
allowSkipMFA: boolean;
apiKeyAuth: boolean;
cors?: Partial<CorsOptions> | true;
rateLimit?: boolean | RateLimit;
licenseFeature?: BooleanLicenseFeature;
accessScope?: AccessScope;
+7
View File
@@ -20,6 +20,7 @@ import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.e
import { License } from '@/license';
import { userHasScopes } from '@/permissions.ee/check-access';
import { send } from '@/response-helper';
import { CorsService } from './services/cors-service';
@Service()
export class ControllerRegistry {
@@ -76,6 +77,12 @@ export class ControllerRegistry {
) as unknown[];
const handler = async (req: Request, res: Response) => {
if (route.cors) {
const corsService = Container.get(CorsService);
const corsOptions = route.cors === true ? {} : route.cors;
corsService.applyCorsHeaders(req, res, corsOptions);
}
const args: unknown[] = [req, res];
for (let index = 0; index < route.args.length; index++) {
const arg = route.args[index];
@@ -0,0 +1,328 @@
import { LicenseState } from '@n8n/backend-common';
import { mockInstance, testDb } from '@n8n/backend-test-utils';
import { CredentialsRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import type { ICredentialResolver } from '@n8n/decorators';
import { Cipher } from 'n8n-core';
import { mock } from 'jest-mock-extended';
import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee';
import { OauthService } from '@/oauth/oauth.service';
import * as utils from '@test-integration/utils';
import { DynamicCredentialsConfig } from '../dynamic-credentials.config';
import { DynamicCredentialResolverRegistry } from '../services';
import { DynamicCredentialResolverRepository } from '../database/repositories/credential-resolver.repository';
// 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);
// Mock DynamicCredentialsConfig before test server is created
mockInstance(DynamicCredentialsConfig, {
corsOrigin: 'https://app.example.com',
corsAllowCredentials: false,
});
const testServer = utils.setupTestServer({
endpointGroups: ['credentials'],
enabledFeatures: ['feat:externalSecrets'],
modules: ['dynamic-credentials'],
});
let credentialsRepository: CredentialsRepository;
let resolverRepository: DynamicCredentialResolverRepository;
let cipher: Cipher;
let oauthService: OauthService;
// Mock resolver for testing
const mockResolver: ICredentialResolver = {
metadata: {
name: 'test-resolver',
description: 'Test resolver for CORS integration tests',
},
async setSecret() {
return;
},
async getSecret() {
return { token: 'test-token', refreshToken: 'test-refresh-token' };
},
async deleteSecret() {},
async validateIdentity() {},
validateOptions: jest.fn(),
};
beforeAll(async () => {
credentialsRepository = Container.get(CredentialsRepository);
resolverRepository = Container.get(DynamicCredentialResolverRepository);
cipher = Container.get(Cipher);
oauthService = Container.get(OauthService);
// Mock OAuth service to avoid actual OAuth flow
oauthService.generateAOauth2AuthUri = jest
.fn()
.mockResolvedValue('https://oauth.example.com/authorize');
mockInstance(EnterpriseCredentialsService);
});
beforeEach(async () => {
await testDb.truncate(['CredentialsEntity', 'DynamicCredentialResolver']);
});
function randomId() {
return Math.random().toString(36).substring(2, 15);
}
describe('POST /credentials/:id/authorize - CORS Integration', () => {
let credentialId: string;
let resolverId: string;
async function setupTestData() {
// Create test credential
const credential = await credentialsRepository.save(
credentialsRepository.create({
id: randomId(),
name: 'Test OAuth2 Credential',
type: 'oAuth2Api',
data: cipher.encrypt({ clientId: 'test-client-id' }),
}),
);
credentialId = credential.id;
// Create test resolver
const resolver = await resolverRepository.save({
id: randomId(),
name: 'Test Resolver',
type: 'test-resolver',
config: cipher.encrypt(JSON.stringify({ apiKey: 'test-api-key' })),
});
resolverId = resolver.id;
// Register mock resolver - access private property using array notation
const registry = Container.get(DynamicCredentialResolverRegistry);
registry['resolverMap'].set('test-resolver', mockResolver);
}
beforeEach(async () => {
await setupTestData();
});
test('should return 204 on OPTIONS preflight with allowed origin', async () => {
const response = await testServer.authlessAgent
.options(`/credentials/${credentialId}/authorize?resolverId=${resolverId}`)
.set('Origin', 'https://app.example.com')
.set('Access-Control-Request-Method', 'POST')
.set('Access-Control-Request-Headers', 'Authorization, Content-Type');
expect(response.status).toBe(204);
expect(response.headers['access-control-allow-origin']).toBe('https://app.example.com');
expect(response.headers['access-control-allow-methods']).toContain('POST');
expect(response.headers['access-control-allow-methods']).toContain('OPTIONS');
expect(response.headers['access-control-allow-headers']).toBeDefined();
expect(response.headers['access-control-allow-headers']).toContain('Authorization');
expect(response.headers['access-control-allow-headers']).toContain('Content-Type');
expect(response.headers['access-control-allow-headers']).toContain('X-Requested-With');
expect(response.headers['access-control-max-age']).toBe('86400');
});
test('should set CORS headers on POST request with allowed origin', async () => {
const response = await testServer.authlessAgent
.post(`/credentials/${credentialId}/authorize?resolverId=${resolverId}`)
.set('Origin', 'https://app.example.com')
.set('Authorization', 'Bearer test-token')
.send();
// Note: The test doesn't verify the full OAuth2 flow, just CORS headers
// A 400 may occur if validateIdentity fails, but we still check CORS headers
expect([200, 400]).toContain(response.status);
expect(response.headers['access-control-allow-origin']).toBe('https://app.example.com');
expect(response.headers['access-control-allow-methods']).toContain('POST');
expect(response.headers['access-control-allow-methods']).toContain('OPTIONS');
});
test('should succeed without CORS headers when Origin header is absent', async () => {
const response = await testServer.authlessAgent
.post(`/credentials/${credentialId}/authorize?resolverId=${resolverId}`)
.set('Authorization', 'Bearer test-token')
.send();
// Explicitly NOT setting Origin header
// Request should succeed
expect([200, 400]).toContain(response.status);
// CORS headers should not be present when no Origin header is sent
expect(response.headers['access-control-allow-origin']).toBeUndefined();
expect(response.headers['access-control-allow-methods']).toBeUndefined();
expect(response.headers['access-control-allow-headers']).toBeUndefined();
expect(response.headers['access-control-max-age']).toBeUndefined();
});
test('should return 404 on OPTIONS preflight with disallowed origin', async () => {
const response = await testServer.authlessAgent
.options(`/credentials/${credentialId}/authorize?resolverId=${resolverId}`)
.set('Origin', 'https://evil.com')
.set('Access-Control-Request-Method', 'POST')
.set('Access-Control-Request-Headers', 'Authorization, Content-Type');
// Disallowed origin should result in 404 (route not matched by custom-cors middleware)
expect(response.status).toBe(404);
expect(response.headers['access-control-allow-origin']).toBeUndefined();
expect(response.headers['access-control-allow-methods']).toBeUndefined();
expect(response.headers['access-control-allow-headers']).toBeUndefined();
});
test('should not set CORS headers with disallowed origin', async () => {
const response = await testServer.authlessAgent
.post(`/credentials/${credentialId}/authorize?resolverId=${resolverId}`)
.set('Origin', 'https://evil.com')
.set('Authorization', 'Bearer test-token')
.send();
// With disallowed origin, request may succeed but CORS headers should not be set
// The request reaches the actual handler since POST doesn't require preflight match
expect([200, 400]).toContain(response.status);
// No CORS headers should be present for disallowed origin
expect(response.headers['access-control-allow-origin']).toBeUndefined();
expect(response.headers['access-control-allow-methods']).toBeUndefined();
});
});
describe('DELETE /credentials/:id/revoke - CORS Integration', () => {
let credentialId: string;
let resolverId: string;
async function setupTestData() {
// Create test credential
const credential = await credentialsRepository.save(
credentialsRepository.create({
id: randomId(),
name: 'Test OAuth2 Credential',
type: 'oAuth2Api',
data: cipher.encrypt({ clientId: 'test-client-id' }),
}),
);
credentialId = credential.id;
// Create test resolver
const resolver = await resolverRepository.save({
id: randomId(),
name: 'Test Resolver',
type: 'test-resolver',
config: cipher.encrypt(JSON.stringify({ apiKey: 'test-api-key' })),
});
resolverId = resolver.id;
// Register mock resolver
const registry = Container.get(DynamicCredentialResolverRegistry);
registry['resolverMap'].set('test-resolver', mockResolver);
}
beforeEach(async () => {
await setupTestData();
});
test('should handle CORS preflight correctly', async () => {
const response = await testServer.authlessAgent
.options(`/credentials/${credentialId}/revoke?resolverId=${resolverId}`)
.set('Origin', 'https://app.example.com')
.set('Access-Control-Request-Method', 'DELETE')
.set('Access-Control-Request-Headers', 'Authorization, Content-Type');
expect(response.status).toBe(204);
expect(response.headers['access-control-allow-origin']).toBe('https://app.example.com');
expect(response.headers['access-control-allow-methods']).toContain('DELETE');
expect(response.headers['access-control-allow-methods']).toContain('OPTIONS');
expect(response.headers['access-control-allow-methods']).not.toContain('POST');
expect(response.headers['access-control-allow-headers']).toBeDefined();
expect(response.headers['access-control-allow-headers']).toContain('Authorization');
expect(response.headers['access-control-allow-headers']).toContain('Content-Type');
expect(response.headers['access-control-allow-headers']).toContain('X-Requested-With');
expect(response.headers['access-control-max-age']).toBe('86400');
});
test('should set CORS headers on DELETE request', async () => {
const response = await testServer.authlessAgent
.delete(`/credentials/${credentialId}/revoke?resolverId=${resolverId}`)
.set('Origin', 'https://app.example.com')
.set('Authorization', 'Bearer test-token');
// Note: The test doesn't verify the full deletion flow, just CORS headers
// A 400 may occur if validation fails, but we still check CORS headers
expect([204, 400]).toContain(response.status);
expect(response.headers['access-control-allow-origin']).toBe('https://app.example.com');
expect(response.headers['access-control-allow-methods']).toContain('DELETE');
expect(response.headers['access-control-allow-methods']).toContain('OPTIONS');
});
});
describe('GET /workflows/:workflowId/execution-status - CORS Integration', () => {
let workflowId: string;
async function setupTestData() {
// Create a simple test workflow ID
workflowId = randomId();
// Register mock resolver
const registry = Container.get(DynamicCredentialResolverRegistry);
registry['resolverMap'].set('test-resolver', mockResolver);
}
beforeEach(async () => {
await setupTestData();
});
test('should handle CORS preflight correctly', async () => {
const response = await testServer.authlessAgent
.options(`/workflows/${workflowId}/execution-status`)
.set('Origin', 'https://app.example.com')
.set('Access-Control-Request-Method', 'GET')
.set('Access-Control-Request-Headers', 'Authorization, Content-Type');
expect(response.status).toBe(204);
expect(response.headers['access-control-allow-origin']).toBe('https://app.example.com');
expect(response.headers['access-control-allow-methods']).toContain('GET');
expect(response.headers['access-control-allow-methods']).toContain('OPTIONS');
expect(response.headers['access-control-allow-methods']).not.toContain('POST');
expect(response.headers['access-control-allow-methods']).not.toContain('DELETE');
expect(response.headers['access-control-allow-headers']).toBeDefined();
expect(response.headers['access-control-allow-headers']).toContain('Authorization');
expect(response.headers['access-control-allow-headers']).toContain('Content-Type');
expect(response.headers['access-control-allow-headers']).toContain('X-Requested-With');
expect(response.headers['access-control-max-age']).toBe('86400');
});
test('should set CORS headers on GET request', async () => {
// Mock the workflow status service to return a valid response
const { CredentialResolverWorkflowService } = await import(
'../services/credential-resolver-workflow.service'
);
const workflowService = Container.get(CredentialResolverWorkflowService);
jest.spyOn(workflowService, 'getWorkflowStatus').mockResolvedValue([
{
credentialId: 'cred-123',
resolverId: 'resolver-123',
credentialName: 'Test Credential',
status: 'configured',
credentialType: 'oAuth2Api',
},
]);
const response = await testServer.authlessAgent
.get(`/workflows/${workflowId}/execution-status`)
.set('Origin', 'https://app.example.com')
.set('Authorization', 'Bearer test-token');
expect(response.status).toBe(200);
expect(response.headers['access-control-allow-origin']).toBe('https://app.example.com');
expect(response.headers['access-control-allow-methods']).toContain('GET');
expect(response.headers['access-control-allow-methods']).toContain('OPTIONS');
expect(response.body.data).toHaveProperty('workflowId', workflowId);
expect(response.body.data).toHaveProperty('readyToExecute');
expect(response.body.data).toHaveProperty('credentials');
});
});
@@ -2,6 +2,7 @@ import { mock } from 'jest-mock-extended';
import type { Request, Response } from 'express';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import type { DynamicCredentialCorsService } from '../services/dynamic-credential-cors.service';
import { WorkflowStatusController } from '../workflow-status.controller';
import type { CredentialResolverWorkflowService } from '../services/credential-resolver-workflow.service';
import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error';
@@ -13,6 +14,7 @@ describe('WorkflowStatusController', () => {
let mockService: jest.Mocked<CredentialResolverWorkflowService>;
let mockUrlService: jest.Mocked<UrlService>;
let mockGlobalConfig: jest.Mocked<GlobalConfig>;
let mockDynamicCredentialCorsService: jest.Mocked<DynamicCredentialCorsService>;
beforeEach(() => {
jest.clearAllMocks();
@@ -31,7 +33,17 @@ describe('WorkflowStatusController', () => {
},
} as unknown as jest.Mocked<GlobalConfig>;
controller = new WorkflowStatusController(mockService, mockUrlService, mockGlobalConfig);
mockDynamicCredentialCorsService = {
applyCorsHeadersIfEnabled: jest.fn(),
preflightHandler: jest.fn(),
} as unknown as jest.Mocked<DynamicCredentialCorsService>;
controller = new WorkflowStatusController(
mockService,
mockUrlService,
mockGlobalConfig,
mockDynamicCredentialCorsService,
);
});
describe('checkWorkflowForExecution', () => {
@@ -0,0 +1,20 @@
import { Config, Env } from '@n8n/config';
@Config
export class DynamicCredentialsConfig {
/**
* Comma-separated list of allowed CORS origins for dynamic credentials endpoints.
* Example: 'https://app.example.com,https://admin.example.com'
* When empty CORS is disabled and endpoints return 404 for preflight requests.
*/
@Env('N8N_DYNAMIC_CREDENTIALS_CORS_ORIGIN')
corsOrigin: string = '';
/**
* Whether to allow credentials (cookies, authorization headers) in CORS requests.
* Must be false when using wildcard (*) in N8N_DYNAMIC_CREDENTIALS_CORS_ORIGIN.
* Default: false
*/
@Env('N8N_DYNAMIC_CREDENTIALS_CORS_ALLOW_CREDENTIALS')
corsAllowCredentials: boolean = false;
}
@@ -1,4 +1,4 @@
import { Delete, Post, RestController } from '@n8n/decorators';
import { Delete, Options, Post, RestController } from '@n8n/decorators';
import { Request, Response } from 'express';
import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee';
@@ -11,6 +11,7 @@ import { DynamicCredentialResolverRegistry } from './services';
import { getBearerToken } from './utils';
import { Cipher } from 'n8n-core';
import { jsonParse } from 'n8n-workflow';
import { DynamicCredentialCorsService } from './services/dynamic-credential-cors.service';
@RestController('/credentials')
export class DynamicCredentialsController {
@@ -20,6 +21,7 @@ export class DynamicCredentialsController {
private readonly resolverRepository: DynamicCredentialResolverRepository,
private readonly resolverRegistry: DynamicCredentialResolverRegistry,
private readonly cipher: Cipher,
private readonly dynamicCredentialCorsService: DynamicCredentialCorsService,
) {}
private async findCredentialToUse(credentialId: string): Promise<CredentialsEntity> {
@@ -57,8 +59,19 @@ export class DynamicCredentialsController {
return { resolver, resolverEntity };
}
/**
* OPTIONS /credentials/:id/revoke
*
* Handles CORS preflight requests
*/
@Options('/:id/revoke', { skipAuth: true })
handlePreflightCredentialRevoke(req: Request, res: Response): void {
this.dynamicCredentialCorsService.preflightHandler(req, res, ['delete', 'options']);
}
@Delete('/:id/revoke', { skipAuth: true })
async revokeCredential(req: Request, res: Response): Promise<void> {
this.dynamicCredentialCorsService.applyCorsHeadersIfEnabled(req, res, ['delete', 'options']);
const token = getBearerToken(req);
const credential = await this.findCredentialToUse(req.params.id);
@@ -87,8 +100,19 @@ export class DynamicCredentialsController {
res.status(204).send(); // 204 No Content indicates successful deletion
}
/**
* OPTIONS /credentials/:id/authorize
*
* Handles CORS preflight requests
*/
@Options('/:id/authorize', { skipAuth: true })
handlePreflightCredentialAuthorize(req: Request, res: Response): void {
this.dynamicCredentialCorsService.preflightHandler(req, res, ['post', 'options']);
}
@Post('/:id/authorize', { skipAuth: true })
async authorizeCredential(req: Request, _res: Response): Promise<string> {
async authorizeCredential(req: Request, res: Response): Promise<string> {
this.dynamicCredentialCorsService.applyCorsHeadersIfEnabled(req, res, ['post', 'options']);
const token = getBearerToken(req);
const credential = await this.findCredentialToUse(req.params.id);
@@ -0,0 +1,242 @@
import type { Request, Response } from 'express';
import type { CorsService } from '@/services/cors-service';
import type { DynamicCredentialsConfig } from '../../dynamic-credentials.config';
import { DynamicCredentialCorsService } from '../dynamic-credential-cors.service';
describe('DynamicCredentialCorsService', () => {
let mockCorsService: jest.Mocked<CorsService>;
let mockConfig: jest.Mocked<DynamicCredentialsConfig>;
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let statusSpy: jest.Mock;
let endSpy: jest.Mock;
beforeEach(() => {
mockCorsService = {
applyCorsHeaders: jest.fn(),
} as unknown as jest.Mocked<CorsService>;
mockConfig = {
corsOrigin: '',
corsAllowCredentials: false,
};
statusSpy = jest.fn().mockReturnThis();
endSpy = jest.fn();
mockRequest = {
headers: {},
};
mockResponse = {
status: statusSpy,
end: endSpy,
};
});
afterEach(() => {
jest.clearAllMocks();
});
describe('constructor()', () => {
it('should disable CORS when corsOrigin is null, empty, or whitespace', () => {
// Test 1: corsOrigin = '' → defaultOptions = null
mockConfig.corsOrigin = '';
const service2 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
expect(service2['defaultOptions']).toBeNull();
// Test 2: corsOrigin = ' ' → defaultOptions = null
mockConfig.corsOrigin = ' ';
const service3 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
expect(service3['defaultOptions']).toBeNull();
// Test 3: corsOrigin = ' , , ' → defaultOptions = null
mockConfig.corsOrigin = ' , , ';
const service4 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
expect(service4['defaultOptions']).toBeNull();
});
it('should parse and trim comma-separated origins correctly', () => {
// Test 1: 'https://app.com' → ['https://app.com']
mockConfig.corsOrigin = 'https://app.com';
const service1 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
expect(service1['defaultOptions']?.allowedOrigins).toEqual(['https://app.com']);
expect(service1['defaultOptions']?.allowedHeaders).toEqual([
'Authorization',
'Content-Type',
'X-Requested-With',
]);
// Test 2: 'https://app.com,https://admin.com' → ['https://app.com', 'https://admin.com']
mockConfig.corsOrigin = 'https://app.com,https://admin.com';
const service2 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
expect(service2['defaultOptions']?.allowedOrigins).toEqual([
'https://app.com',
'https://admin.com',
]);
// Test 3: ' https://app.com , https://admin.com ' → trimmed and filtered
mockConfig.corsOrigin = ' https://app.com , https://admin.com ';
const service3 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
expect(service3['defaultOptions']?.allowedOrigins).toEqual([
'https://app.com',
'https://admin.com',
]);
expect(service3['defaultOptions']?.allowedHeaders).toEqual([
'Authorization',
'Content-Type',
'X-Requested-With',
]);
});
it('should set allowCredentials from config', () => {
mockConfig.corsOrigin = 'https://app.com';
// Test 1: corsAllowCredentials = true → defaultOptions.allowCredentials = true
mockConfig.corsAllowCredentials = true;
const service1 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
expect(service1['defaultOptions']?.allowCredentials).toBe(true);
// Test 2: corsAllowCredentials = false → defaultOptions.allowCredentials = false
mockConfig.corsAllowCredentials = false;
const service2 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
expect(service2['defaultOptions']?.allowCredentials).toBe(false);
});
it('should throw error when wildcard used with allowCredentials true', () => {
// Test: corsOrigin = '*', corsAllowCredentials = true
mockConfig.corsOrigin = '*';
mockConfig.corsAllowCredentials = true;
// Expected: throws error with message about wildcard + credentials
expect(() => new DynamicCredentialCorsService(mockCorsService, mockConfig)).toThrow(
'N8N_DYNAMIC_CREDENTIALS_CORS_ORIGIN cannot use wildcard (*) when ' +
'N8N_DYNAMIC_CREDENTIALS_CORS_ALLOW_CREDENTIALS is true. Specify explicit origins instead.',
);
});
it('should allow wildcard when allowCredentials is false', () => {
// Test: corsOrigin = '*', corsAllowCredentials = false
mockConfig.corsOrigin = '*';
mockConfig.corsAllowCredentials = false;
// Expected: no error, defaultOptions set with wildcard
const service = new DynamicCredentialCorsService(mockCorsService, mockConfig);
expect(service['defaultOptions']?.allowedOrigins).toEqual(['*']);
expect(service['defaultOptions']?.allowCredentials).toBe(false);
});
});
describe('preflightHandler()', () => {
it('should return 204 when CORS enabled and origin allowed, 404 otherwise', () => {
// Test 1: CORS disabled (corsOrigin="") → 404
mockConfig.corsOrigin = '';
const service1 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
service1.preflightHandler(mockRequest as Request, mockResponse as Response, [
'post',
'options',
]);
expect(statusSpy).toHaveBeenCalledWith(404);
expect(endSpy).toHaveBeenCalled();
// Test 2: CORS enabled, origin not allowed → 404
jest.clearAllMocks();
mockConfig.corsOrigin = 'https://app.com';
mockCorsService.applyCorsHeaders.mockReturnValue(false);
const service2 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
service2.preflightHandler(mockRequest as Request, mockResponse as Response, [
'post',
'options',
]);
expect(statusSpy).toHaveBeenCalledWith(404);
expect(endSpy).toHaveBeenCalled();
// Test 3: CORS enabled, origin allowed → 204
jest.clearAllMocks();
mockConfig.corsOrigin = 'https://app.com';
mockCorsService.applyCorsHeaders.mockReturnValue(true);
const service3 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
service3.preflightHandler(mockRequest as Request, mockResponse as Response, [
'post',
'options',
]);
expect(statusSpy).toHaveBeenCalledWith(204);
expect(endSpy).toHaveBeenCalled();
});
it('should pass allowedMethods to CORS service', () => {
// Test: call with ['post', 'options']
mockConfig.corsOrigin = 'https://app.com';
mockConfig.corsAllowCredentials = true;
mockCorsService.applyCorsHeaders.mockReturnValue(true);
const service = new DynamicCredentialCorsService(mockCorsService, mockConfig);
service.preflightHandler(mockRequest as Request, mockResponse as Response, [
'post',
'options',
]);
// Verify: corsService.applyCorsHeaders called with allowedMethods
expect(mockCorsService.applyCorsHeaders).toHaveBeenCalledWith(mockRequest, mockResponse, {
allowedOrigins: ['https://app.com'],
allowedHeaders: ['Authorization', 'Content-Type', 'X-Requested-With'],
allowCredentials: true,
allowedMethods: ['post', 'options'],
});
});
});
describe('applyCorsHeadersIfEnabled()', () => {
it('should return false when CORS disabled or origin not allowed', () => {
// Test 1: CORS disabled → returns false, corsService not called
mockConfig.corsOrigin = '';
const service1 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
const result1 = service1.applyCorsHeadersIfEnabled(
mockRequest as Request,
mockResponse as Response,
['get', 'options'],
);
expect(result1).toBe(false);
expect(mockCorsService.applyCorsHeaders).not.toHaveBeenCalled();
// Test 2: origin not allowed → corsService returns false, method returns false
jest.clearAllMocks();
mockConfig.corsOrigin = 'https://app.com';
mockCorsService.applyCorsHeaders.mockReturnValue(false);
const service2 = new DynamicCredentialCorsService(mockCorsService, mockConfig);
const result2 = service2.applyCorsHeadersIfEnabled(
mockRequest as Request,
mockResponse as Response,
['get', 'options'],
);
expect(result2).toBe(false);
expect(mockCorsService.applyCorsHeaders).toHaveBeenCalled();
});
it('should return true and merge options when origin allowed', () => {
// Test: CORS enabled, origin allowed, allowedMethods=['get', 'options']
mockConfig.corsOrigin = 'https://app.com';
mockConfig.corsAllowCredentials = true;
mockCorsService.applyCorsHeaders.mockReturnValue(true);
const service = new DynamicCredentialCorsService(mockCorsService, mockConfig);
const result = service.applyCorsHeadersIfEnabled(
mockRequest as Request,
mockResponse as Response,
['get', 'options'],
);
// Verify: corsService called with merged options (defaultOptions + allowedMethods)
expect(mockCorsService.applyCorsHeaders).toHaveBeenCalledWith(mockRequest, mockResponse, {
allowedOrigins: ['https://app.com'],
allowedHeaders: ['Authorization', 'Content-Type', 'X-Requested-With'],
allowCredentials: true,
allowedMethods: ['get', 'options'],
});
// returns true
expect(result).toBe(true);
});
});
});
@@ -0,0 +1,68 @@
import { CorsService } from '@/services/cors-service';
import type { CorsOptions, Method } from '@n8n/decorators';
import { Service } from '@n8n/di';
import type { Request, Response } from 'express';
import { DynamicCredentialsConfig } from '../dynamic-credentials.config';
@Service()
export class DynamicCredentialCorsService {
private readonly defaultOptions: Partial<CorsOptions> | null;
constructor(
private readonly corsService: CorsService,
private readonly dynamicCredentialConfig: DynamicCredentialsConfig,
) {
if (this.dynamicCredentialConfig.corsOrigin === null) {
this.defaultOptions = null;
return;
}
const corsOriginConfig = this.dynamicCredentialConfig.corsOrigin?.trim();
if (!corsOriginConfig) {
this.defaultOptions = null;
return;
}
const allowedOrigins = corsOriginConfig
.split(',')
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);
// Add this check:
if (allowedOrigins.length === 0) {
this.defaultOptions = null;
return;
}
if (this.dynamicCredentialConfig.corsAllowCredentials && allowedOrigins.includes('*')) {
throw new Error(
'N8N_DYNAMIC_CREDENTIALS_CORS_ORIGIN cannot use wildcard (*) when ' +
'N8N_DYNAMIC_CREDENTIALS_CORS_ALLOW_CREDENTIALS is true. Specify explicit origins instead.',
);
}
this.defaultOptions = {
allowedOrigins,
allowedHeaders: ['Authorization', 'Content-Type', 'X-Requested-With'],
allowCredentials: this.dynamicCredentialConfig.corsAllowCredentials,
};
}
preflightHandler(req: Request, res: Response, allowedMethods: Method[]): void {
if (this.applyCorsHeadersIfEnabled(req, res, allowedMethods)) {
res.status(204).end();
return;
}
res.status(404).end();
}
applyCorsHeadersIfEnabled(req: Request, res: Response, allowedMethods: Method[]): boolean {
if (this.defaultOptions !== null) {
return this.corsService.applyCorsHeaders(req, res, {
...this.defaultOptions,
allowedMethods,
});
}
return false;
}
}
@@ -1,4 +1,4 @@
import { Get, RestController } from '@n8n/decorators';
import { Get, Options, RestController } from '@n8n/decorators';
import { Request, Response } from 'express';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@@ -7,6 +7,7 @@ import { WorkflowExecutionStatus } from '@n8n/api-types';
import { getBearerToken } from './utils';
import { UrlService } from '@/services/url.service';
import { GlobalConfig } from '@n8n/config';
import { DynamicCredentialCorsService } from './services/dynamic-credential-cors.service';
@RestController('/workflows')
export class WorkflowStatusController {
@@ -14,8 +15,19 @@ export class WorkflowStatusController {
private readonly credentialResolverWorkflowService: CredentialResolverWorkflowService,
private readonly urlService: UrlService,
private readonly globalConfig: GlobalConfig,
private readonly dynamicCredentialCorsService: DynamicCredentialCorsService,
) {}
/**
* OPTIONS /workflows/:workflowId/execution-status
*
* Handles CORS preflight requests
*/
@Options('/:workflowId/execution-status', { skipAuth: true })
handlePreflightExecutionStatus(req: Request, res: Response): void {
this.dynamicCredentialCorsService.preflightHandler(req, res, ['get', 'options']);
}
/**
* GET /workflows/:workflowId/execution-status
*
@@ -26,7 +38,8 @@ export class WorkflowStatusController {
* @throws {BadRequestError} When authorization header is missing or malformed
*/
@Get('/:workflowId/execution-status', { skipAuth: true })
async checkWorkflowForExecution(req: Request, _res: Response): Promise<WorkflowExecutionStatus> {
async checkWorkflowForExecution(req: Request, res: Response): Promise<WorkflowExecutionStatus> {
this.dynamicCredentialCorsService.applyCorsHeadersIfEnabled(req, res, ['get', 'options']);
const workflowId = req.params['workflowId'];
const token = getBearerToken(req);
@@ -0,0 +1,168 @@
import type { Request, Response } from 'express';
import { CorsService } from '../cors-service';
describe('CorsService', () => {
let corsService: CorsService;
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let headerSpy: jest.Mock;
beforeEach(() => {
corsService = new CorsService();
headerSpy = jest.fn();
mockRequest = {
headers: {},
};
mockResponse = {
header: headerSpy,
};
});
afterEach(() => {
jest.clearAllMocks();
});
describe('applyCorsHeaders()', () => {
it('should return false when no origin header present or origin not allowed', () => {
// Test 1: req.headers.origin = undefined → returns false
const result1 = corsService.applyCorsHeaders(
mockRequest as Request,
mockResponse as Response,
);
expect(result1).toBe(false);
expect(headerSpy).not.toHaveBeenCalled();
// Test 2: origin='https://evil.com', allowedOrigins=['https://app.com'] → returns false
mockRequest.headers = { origin: 'https://evil.com' };
const result2 = corsService.applyCorsHeaders(
mockRequest as Request,
mockResponse as Response,
{ allowedOrigins: ['https://app.com'] },
);
expect(result2).toBe(false);
expect(headerSpy).not.toHaveBeenCalled();
});
it('should allow origin when in allowed list or wildcard present', () => {
// Test 1: origin='https://app.com', allowedOrigins=['https://app.com'] → true
mockRequest.headers = { origin: 'https://app.com' };
const result1 = corsService.applyCorsHeaders(
mockRequest as Request,
mockResponse as Response,
{ allowedOrigins: ['https://app.com'] },
);
expect(result1).toBe(true);
expect(headerSpy).toHaveBeenCalledWith('Access-Control-Allow-Origin', 'https://app.com');
// Reset for next test
jest.clearAllMocks();
// Test 2: origin='https://anything.com', allowedOrigins=['*'] → true
mockRequest.headers = { origin: 'https://anything.com' };
const result2 = corsService.applyCorsHeaders(
mockRequest as Request,
mockResponse as Response,
{ allowedOrigins: ['*'] },
);
expect(result2).toBe(true);
expect(headerSpy).toHaveBeenCalledWith('Access-Control-Allow-Origin', 'https://anything.com');
});
it('should set all CORS headers correctly when origin is allowed', () => {
mockRequest.headers = { origin: 'https://app.com' };
const result = corsService.applyCorsHeaders(
mockRequest as Request,
mockResponse as Response,
{
allowedOrigins: ['https://app.com'],
allowedMethods: ['get', 'post'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400,
},
);
expect(result).toBe(true);
expect(headerSpy).toHaveBeenCalledWith('Access-Control-Allow-Origin', 'https://app.com');
expect(headerSpy).toHaveBeenCalledWith('Access-Control-Allow-Methods', 'GET, POST');
expect(headerSpy).toHaveBeenCalledWith(
'Access-Control-Allow-Headers',
'Content-Type, Authorization',
);
expect(headerSpy).toHaveBeenCalledWith('Access-Control-Max-Age', '86400');
});
it('should set Access-Control-Allow-Credentials when explicitly provided', () => {
mockRequest.headers = { origin: 'https://app.com' };
// Test 1: allowCredentials=true → header 'true'
corsService.applyCorsHeaders(mockRequest as Request, mockResponse as Response, {
allowedOrigins: ['https://app.com'],
allowCredentials: true,
});
expect(headerSpy).toHaveBeenCalledWith('Access-Control-Allow-Credentials', 'true');
// Reset for next test
jest.clearAllMocks();
// Test 2: allowCredentials=false → header 'false'
corsService.applyCorsHeaders(mockRequest as Request, mockResponse as Response, {
allowedOrigins: ['https://app.com'],
allowCredentials: false,
});
expect(headerSpy).toHaveBeenCalledWith('Access-Control-Allow-Credentials', 'false');
// Reset for next test
jest.clearAllMocks();
// Test 3: allowCredentials=undefined → header not set
corsService.applyCorsHeaders(mockRequest as Request, mockResponse as Response, {
allowedOrigins: ['https://app.com'],
});
const hasCredentialsHeader = headerSpy.mock.calls.some(
(call: string[]) => call[0] === 'Access-Control-Allow-Credentials',
);
expect(hasCredentialsHeader).toBe(false);
});
it('should merge custom options with defaults', () => {
mockRequest.headers = { origin: 'https://app.com' };
const result = corsService.applyCorsHeaders(
mockRequest as Request,
mockResponse as Response,
{ allowedOrigins: ['https://app.com'] },
);
expect(result).toBe(true);
// Verify custom origin is used
expect(headerSpy).toHaveBeenCalledWith('Access-Control-Allow-Origin', 'https://app.com');
// Verify default methods are used (uppercase)
expect(headerSpy).toHaveBeenCalledWith(
'Access-Control-Allow-Methods',
'GET, POST, OPTIONS, PUT, PATCH, DELETE',
);
// Verify default headers are used
expect(headerSpy).toHaveBeenCalledWith(
'Access-Control-Allow-Headers',
'Origin, X-Requested-With, Content-Type, Accept',
);
// Verify default maxAge is used
expect(headerSpy).toHaveBeenCalledWith('Access-Control-Max-Age', '86400');
});
});
});
+52
View File
@@ -0,0 +1,52 @@
import type { CorsOptions } from '@n8n/decorators';
import { Service } from '@n8n/di';
import type { Response, Request } from 'express';
@Service()
export class CorsService {
private readonly defaultOptions: CorsOptions = {
allowedOrigins: ['*'],
allowedMethods: ['get', 'post', 'options', 'put', 'patch', 'delete'],
allowedHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept'],
maxAge: 86400,
};
/**
* Apply CORS headers to response based on request origin
* @returns true if origin is allowed, false otherwise
*/
applyCorsHeaders(req: Request, res: Response, options?: Partial<CorsOptions>): boolean {
const config = { ...this.defaultOptions, ...options };
const requestOrigin = req.headers.origin;
if (!requestOrigin) return false;
const isAllowed = this.isOriginAllowed(requestOrigin, config.allowedOrigins ?? []);
if (!isAllowed) return false;
res.header('Access-Control-Allow-Origin', requestOrigin);
if (config.allowCredentials !== undefined) {
res.header('Access-Control-Allow-Credentials', `${config.allowCredentials}`);
}
if (config.allowedMethods) {
res.header('Access-Control-Allow-Methods', config.allowedMethods.join(', ').toUpperCase());
}
if (config.allowedHeaders) {
res.header('Access-Control-Allow-Headers', config.allowedHeaders.join(', '));
}
if (config.maxAge) {
res.header('Access-Control-Max-Age', config.maxAge.toString());
}
return true;
}
private isOriginAllowed(origin: string, allowedOrigins: string[]): boolean {
return allowedOrigins.includes('*') || allowedOrigins.includes(origin);
}
}