Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/cli/src/utils/__tests__/cors.util.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
12 changes: 10 additions & 2 deletions packages/cli/src/utils/cors.util.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
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;
}

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);
}
Expand Down
26 changes: 21 additions & 5 deletions packages/cli/src/webhooks/__tests__/waiting-forms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -16,7 +17,16 @@ class TestWaitingForms extends WaitingForms {

describe('WaitingForms', () => {
const executionRepository = mock<ExecutionRepository>();
const waitingForms = new TestWaitingForms(mock(), mock(), executionRepository, mock(), mock());
const urlService = mock<UrlService>();
urlService.getWebhookBaseUrl.mockReturnValue('http://localhost:5678/');
const waitingForms = new TestWaitingForms(
mock(),
mock(),
executionRepository,
mock(),
mock(),
urlService,
);

beforeEach(() => {
jest.restoreAllMocks();
Expand Down Expand Up @@ -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<IExecutionResponse>({
status: 'success',
});
Expand All @@ -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 () => {
Expand All @@ -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<IExecutionResponse>({
status: 'success',
});
Expand All @@ -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 () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,12 +27,15 @@ describe('WaitingWebhooks', () => {
const mockInstanceSettings = mock<InstanceSettings>({
hmacSignatureSecret: SIGNING_SECRET,
});
const urlService = mock<UrlService>();
urlService.getWebhookBaseUrl.mockReturnValue('http://localhost:5678/');
const waitingWebhooks = new TestWaitingWebhooks(
mock(),
mock(),
executionRepository,
mockWebhookService,
mockInstanceSettings,
urlService,
);

beforeEach(() => {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/webhooks/waiting-forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export class WaitingForms extends WaitingWebhooks {
}
}

applyCors(req, res);
applyCors(req, res, this.instanceOrigin);

res.send(status);
return { noWebhookResponse: true };
Expand Down Expand Up @@ -147,7 +147,7 @@ export class WaitingForms extends WaitingWebhooks {
}
}

applyCors(req, res);
applyCors(req, res, this.instanceOrigin);

return await this.getWebhookExecutionData({
execution,
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/webhooks/waiting-webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
prepareUrlForSigning,
generateUrlSignature,
} from 'n8n-core';
import { UrlService } from '@/services/url.service';
import {
FORM_NODE_TYPE,
type INodes,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down