From ee9c1d77dad81d3e888b1f8480ccaaa9ace6fddb Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Wed, 14 Jan 2026 18:17:58 +0100 Subject: [PATCH] chore(core): Introduce CORS settings for dynamic credential interface (#24324) --- .../@n8n/decorators/src/controller/index.ts | 9 +- .../@n8n/decorators/src/controller/route.ts | 5 +- .../@n8n/decorators/src/controller/types.ts | 9 + packages/cli/src/controller.registry.ts | 7 + ...namic-credentials-cors.integration.test.ts | 328 ++++++++++++++++++ .../workflow-status.controller.test.ts | 14 +- .../dynamic-credentials.config.ts | 20 ++ .../dynamic-credentials.controller.ts | 28 +- .../dynamic-credential-cors.service.test.ts | 242 +++++++++++++ .../dynamic-credential-cors.service.ts | 68 ++++ .../workflow-status.controller.ts | 17 +- .../services/__tests__/cors-service.test.ts | 168 +++++++++ packages/cli/src/services/cors-service.ts | 52 +++ 13 files changed, 960 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/modules/dynamic-credentials.ee/__tests__/dynamic-credentials-cors.integration.test.ts create mode 100644 packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.config.ts create mode 100644 packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/dynamic-credential-cors.service.test.ts create mode 100644 packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential-cors.service.ts create mode 100644 packages/cli/src/services/__tests__/cors-service.test.ts create mode 100644 packages/cli/src/services/cors-service.ts diff --git a/packages/@n8n/decorators/src/controller/index.ts b/packages/@n8n/decorators/src/controller/index.ts index 6b91dcd6a7e..da6698438fa 100644 --- a/packages/@n8n/decorators/src/controller/index.ts +++ b/packages/@n8n/decorators/src/controller/index.ts @@ -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'; diff --git a/packages/@n8n/decorators/src/controller/route.ts b/packages/@n8n/decorators/src/controller/route.ts index 02f89968460..27362736bd7 100644 --- a/packages/@n8n/decorators/src/controller/route.ts +++ b/packages/@n8n/decorators/src/controller/route.ts @@ -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 | 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'); diff --git a/packages/@n8n/decorators/src/controller/types.ts b/packages/@n8n/decorators/src/controller/types.ts index b9ebfc445bb..18fc6ec8676 100644 --- a/packages/@n8n/decorators/src/controller/types.ts +++ b/packages/@n8n/decorators/src/controller/types.ts @@ -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 | true; rateLimit?: boolean | RateLimit; licenseFeature?: BooleanLicenseFeature; accessScope?: AccessScope; diff --git a/packages/cli/src/controller.registry.ts b/packages/cli/src/controller.registry.ts index ccfe721d326..ab0834bd1f2 100644 --- a/packages/cli/src/controller.registry.ts +++ b/packages/cli/src/controller.registry.ts @@ -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]; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/__tests__/dynamic-credentials-cors.integration.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/__tests__/dynamic-credentials-cors.integration.test.ts new file mode 100644 index 00000000000..121abbcba50 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/__tests__/dynamic-credentials-cors.integration.test.ts @@ -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(); +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'); + }); +}); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/__tests__/workflow-status.controller.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/__tests__/workflow-status.controller.test.ts index 06eb694303a..c523a94bd63 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/__tests__/workflow-status.controller.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/__tests__/workflow-status.controller.test.ts @@ -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; let mockUrlService: jest.Mocked; let mockGlobalConfig: jest.Mocked; + let mockDynamicCredentialCorsService: jest.Mocked; beforeEach(() => { jest.clearAllMocks(); @@ -31,7 +33,17 @@ describe('WorkflowStatusController', () => { }, } as unknown as jest.Mocked; - controller = new WorkflowStatusController(mockService, mockUrlService, mockGlobalConfig); + mockDynamicCredentialCorsService = { + applyCorsHeadersIfEnabled: jest.fn(), + preflightHandler: jest.fn(), + } as unknown as jest.Mocked; + + controller = new WorkflowStatusController( + mockService, + mockUrlService, + mockGlobalConfig, + mockDynamicCredentialCorsService, + ); }); describe('checkWorkflowForExecution', () => { 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 new file mode 100644 index 00000000000..936999de45d --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.config.ts @@ -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; +} 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 082b7337edd..37ad936a487 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,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 { @@ -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 { + 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 { + async authorizeCredential(req: Request, res: Response): Promise { + this.dynamicCredentialCorsService.applyCorsHeadersIfEnabled(req, res, ['post', 'options']); const token = getBearerToken(req); const credential = await this.findCredentialToUse(req.params.id); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/dynamic-credential-cors.service.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/dynamic-credential-cors.service.test.ts new file mode 100644 index 00000000000..1b3cfb7286c --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/dynamic-credential-cors.service.test.ts @@ -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; + let mockConfig: jest.Mocked; + let mockRequest: Partial; + let mockResponse: Partial; + let statusSpy: jest.Mock; + let endSpy: jest.Mock; + + beforeEach(() => { + mockCorsService = { + applyCorsHeaders: jest.fn(), + } as unknown as jest.Mocked; + + 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); + }); + }); +}); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential-cors.service.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential-cors.service.ts new file mode 100644 index 00000000000..7b34195e1ec --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential-cors.service.ts @@ -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 | 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; + } +} 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 a452f7bef09..5ad48173e31 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,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 { + async checkWorkflowForExecution(req: Request, res: Response): Promise { + this.dynamicCredentialCorsService.applyCorsHeadersIfEnabled(req, res, ['get', 'options']); const workflowId = req.params['workflowId']; const token = getBearerToken(req); diff --git a/packages/cli/src/services/__tests__/cors-service.test.ts b/packages/cli/src/services/__tests__/cors-service.test.ts new file mode 100644 index 00000000000..1d35bfdadb1 --- /dev/null +++ b/packages/cli/src/services/__tests__/cors-service.test.ts @@ -0,0 +1,168 @@ +import type { Request, Response } from 'express'; + +import { CorsService } from '../cors-service'; + +describe('CorsService', () => { + let corsService: CorsService; + let mockRequest: Partial; + let mockResponse: Partial; + 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'); + }); + }); +}); diff --git a/packages/cli/src/services/cors-service.ts b/packages/cli/src/services/cors-service.ts new file mode 100644 index 00000000000..18fbeaea705 --- /dev/null +++ b/packages/cli/src/services/cors-service.ts @@ -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): 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); + } +}