diff --git a/packages/cli/src/utils/__tests__/cors.util.test.ts b/packages/cli/src/utils/__tests__/cors.util.test.ts index 614a8dd7f3e..bd17fb76769 100644 --- a/packages/cli/src/utils/__tests__/cors.util.test.ts +++ b/packages/cli/src/utils/__tests__/cors.util.test.ts @@ -60,6 +60,46 @@ describe('applyCors', () => { expect(setHeaderSpy).toHaveBeenCalledTimes(3); }); + it('should use instanceOrigin instead of * when origin is "null" and instanceOrigin is provided', () => { + getHeaderSpy.mockReturnValue(undefined); + mockReq.headers = { origin: 'null' }; + + applyCors(mockReq as Request, mockRes as Response, 'https://n8n.example.com'); + + expect(setHeaderSpy).toHaveBeenCalledWith( + 'Access-Control-Allow-Origin', + 'https://n8n.example.com', + ); + expect(setHeaderSpy).toHaveBeenCalledWith('Access-Control-Allow-Methods', 'POST, GET, OPTIONS'); + expect(setHeaderSpy).toHaveBeenCalledWith('Access-Control-Allow-Headers', 'Content-Type'); + expect(setHeaderSpy).toHaveBeenCalledTimes(3); + }); + + it('should use instanceOrigin instead of * when origin is undefined and instanceOrigin is provided', () => { + getHeaderSpy.mockReturnValue(undefined); + mockReq.headers = {}; + + applyCors(mockReq as Request, mockRes as Response, 'https://n8n.example.com'); + + expect(setHeaderSpy).toHaveBeenCalledWith( + 'Access-Control-Allow-Origin', + 'https://n8n.example.com', + ); + expect(setHeaderSpy).toHaveBeenCalledWith('Access-Control-Allow-Methods', 'POST, GET, OPTIONS'); + expect(setHeaderSpy).toHaveBeenCalledWith('Access-Control-Allow-Headers', 'Content-Type'); + expect(setHeaderSpy).toHaveBeenCalledTimes(3); + }); + + it('should ignore instanceOrigin and use the real request origin when origin is a non-null value', () => { + getHeaderSpy.mockReturnValue(undefined); + const origin = 'https://other.example.com'; + mockReq.headers = { origin }; + + applyCors(mockReq as Request, mockRes as Response, 'https://n8n.example.com'); + + expect(setHeaderSpy).toHaveBeenCalledWith('Access-Control-Allow-Origin', origin); + }); + it('should set Access-Control-Allow-Origin to the request origin when origin is provided', () => { getHeaderSpy.mockReturnValue(undefined); const origin = 'https://example.com'; diff --git a/packages/cli/src/utils/cors.util.ts b/packages/cli/src/utils/cors.util.ts index e9600426a8c..25f1bfc2ef1 100644 --- a/packages/cli/src/utils/cors.util.ts +++ b/packages/cli/src/utils/cors.util.ts @@ -1,6 +1,14 @@ import type { Request, Response } from 'express'; -export function applyCors(req: Request, res: Response) { +/** + * Apply CORS headers to the response. + * + * When the request carries `Origin: null` (e.g. pages served under a CSP + * `sandbox` directive), pass the configured n8n instance origin as + * `instanceOrigin` so that reverse proxies that block wildcard `*` responses + * still forward the header rather than stripping it. + */ +export function applyCors(req: Request, res: Response, instanceOrigin?: string) { if (res.getHeader('Access-Control-Allow-Origin')) { return; } @@ -8,7 +16,7 @@ export function applyCors(req: Request, res: Response) { const origin = req.headers.origin; if (!origin || origin === 'null') { - res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Origin', instanceOrigin ?? '*'); } else { res.setHeader('Access-Control-Allow-Origin', origin); } diff --git a/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts b/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts index d71d2fb33aa..15b3c363418 100644 --- a/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts +++ b/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts @@ -6,6 +6,7 @@ import { FORM_NODE_TYPE, WAITING_FORMS_EXECUTION_STATUS, type Workflow } from 'n import type { WaitingWebhookRequest } from '../webhook.types'; +import type { UrlService } from '@/services/url.service'; import { WaitingForms } from '@/webhooks/waiting-forms'; class TestWaitingForms extends WaitingForms { @@ -16,7 +17,16 @@ class TestWaitingForms extends WaitingForms { describe('WaitingForms', () => { const executionRepository = mock(); - const waitingForms = new TestWaitingForms(mock(), mock(), executionRepository, mock(), mock()); + const urlService = mock(); + urlService.getWebhookBaseUrl.mockReturnValue('http://localhost:5678/'); + const waitingForms = new TestWaitingForms( + mock(), + mock(), + executionRepository, + mock(), + mock(), + urlService, + ); beforeEach(() => { jest.restoreAllMocks(); @@ -228,7 +238,7 @@ describe('WaitingForms', () => { expect(res.send).toHaveBeenCalledWith(execution.status); }); - it('should set CORS headers when origin header is present for status endpoint', async () => { + it('should set CORS headers to instance origin when origin is "null" for status endpoint', async () => { const execution = mock({ status: 'success', }); @@ -246,7 +256,10 @@ describe('WaitingForms', () => { await waitingForms.executeWebhook(req, res); - expect(res.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Origin', '*'); + expect(res.setHeader).toHaveBeenCalledWith( + 'Access-Control-Allow-Origin', + 'http://localhost:5678', + ); }); it('should not override existing Access-Control-Allow-Origin header', async () => { @@ -271,7 +284,7 @@ describe('WaitingForms', () => { expect(res.setHeader).not.toHaveBeenCalledWith('Access-Control-Allow-Origin', '*'); }); - it('should set CORS headers to wildcard when origin header is missing for status endpoint', async () => { + it('should set CORS headers to instance origin when origin header is missing for status endpoint', async () => { const execution = mock({ status: 'success', }); @@ -289,7 +302,10 @@ describe('WaitingForms', () => { await waitingForms.executeWebhook(req, res); - expect(res.setHeader).toHaveBeenCalledWith('Access-Control-Allow-Origin', '*'); + expect(res.setHeader).toHaveBeenCalledWith( + 'Access-Control-Allow-Origin', + 'http://localhost:5678', + ); }); it('should not set CORS headers for non-status endpoints', async () => { diff --git a/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts index 472614fb7b6..e613b8ce383 100644 --- a/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts @@ -7,6 +7,7 @@ import type { IWorkflowBase, Workflow } from 'n8n-workflow'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import type { UrlService } from '@/services/url.service'; import * as WebhookHelpers from '@/webhooks/webhook-helpers'; import { WaitingWebhooks } from '@/webhooks/waiting-webhooks'; import type { WebhookService } from '@/webhooks/webhook.service'; @@ -26,12 +27,15 @@ describe('WaitingWebhooks', () => { const mockInstanceSettings = mock({ hmacSignatureSecret: SIGNING_SECRET, }); + const urlService = mock(); + urlService.getWebhookBaseUrl.mockReturnValue('http://localhost:5678/'); const waitingWebhooks = new TestWaitingWebhooks( mock(), mock(), executionRepository, mockWebhookService, mockInstanceSettings, + urlService, ); beforeEach(() => { diff --git a/packages/cli/src/webhooks/waiting-forms.ts b/packages/cli/src/webhooks/waiting-forms.ts index b3c66060e92..78d093888c7 100644 --- a/packages/cli/src/webhooks/waiting-forms.ts +++ b/packages/cli/src/webhooks/waiting-forms.ts @@ -98,7 +98,7 @@ export class WaitingForms extends WaitingWebhooks { } } - applyCors(req, res); + applyCors(req, res, this.instanceOrigin); res.send(status); return { noWebhookResponse: true }; @@ -147,7 +147,7 @@ export class WaitingForms extends WaitingWebhooks { } } - applyCors(req, res); + applyCors(req, res, this.instanceOrigin); return await this.getWebhookExecutionData({ execution, diff --git a/packages/cli/src/webhooks/waiting-webhooks.ts b/packages/cli/src/webhooks/waiting-webhooks.ts index d8e8c642ac9..0ba1ce35a97 100644 --- a/packages/cli/src/webhooks/waiting-webhooks.ts +++ b/packages/cli/src/webhooks/waiting-webhooks.ts @@ -10,6 +10,7 @@ import { prepareUrlForSigning, generateUrlSignature, } from 'n8n-core'; +import { UrlService } from '@/services/url.service'; import { FORM_NODE_TYPE, type INodes, @@ -45,13 +46,18 @@ import { applyCors } from '@/utils/cors.util'; export class WaitingWebhooks implements IWebhookManager { protected includeForms = false; + protected readonly instanceOrigin: string; + constructor( protected readonly logger: Logger, protected readonly nodeTypes: NodeTypes, private readonly executionRepository: ExecutionRepository, private readonly webhookService: WebhookService, private readonly instanceSettings: InstanceSettings, - ) {} + urlService: UrlService, + ) { + this.instanceOrigin = new URL(urlService.getWebhookBaseUrl()).origin; + } // TODO: implement `getWebhookMethods` for CORS support @@ -180,7 +186,7 @@ export class WaitingWebhooks implements IWebhookManager { const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string; - applyCors(req, res); + applyCors(req, res, this.instanceOrigin); return await this.getWebhookExecutionData({ execution,