Skip to content

Commit 1ed8239

Browse files
feat: Env to disable webhook response iframe sandboxing (#17851)
1 parent b89c254 commit 1ed8239

File tree

6 files changed

+79
-8
lines changed

6 files changed

+79
-8
lines changed

packages/@n8n/config/src/configs/security.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,8 @@ export class SecurityConfig {
3838
*/
3939
@Env('N8N_CONTENT_SECURITY_POLICY_REPORT_ONLY')
4040
contentSecurityPolicyReportOnly: boolean = false;
41+
42+
/** Whether to disable iframe sandboxing for webhooks */
43+
@Env('N8N_INSECURE_DISABLE_WEBHOOK_IFRAME_SANDBOX')
44+
disableIframeSandboxing: boolean = false;
4145
}

packages/@n8n/config/test/config.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ describe('GlobalConfig', () => {
303303
daysAbandonedWorkflow: 90,
304304
contentSecurityPolicy: '{}',
305305
contentSecurityPolicyReportOnly: false,
306+
disableIframeSandboxing: false,
306307
},
307308
executions: {
308309
pruneData: true,

packages/cli/src/webhooks/webhook-request-handler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ class WebhookRequestHandler {
151151
} else {
152152
const needsSandbox = contentType && isHtmlRenderedContentType(contentType);
153153
if (needsSandbox) {
154-
res.send(sandboxHtmlResponse(JSON.stringify(body)));
154+
res.send(sandboxHtmlResponse(body));
155155
} else {
156156
res.json(body);
157157
}

packages/core/src/__tests__/html-sandbox.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import type { SecurityConfig } from '@n8n/config';
2+
import { Container } from '@n8n/di';
3+
import { mock } from 'jest-mock-extended';
14
import { Readable } from 'stream';
25

36
import {
@@ -18,7 +21,16 @@ async function consumeStreamToString(stream: NodeJS.ReadableStream): Promise<str
1821
});
1922
}
2023

24+
const securityConfig = mock<SecurityConfig>();
25+
2126
describe('sandboxHtmlResponse', () => {
27+
beforeAll(() => {
28+
securityConfig.disableIframeSandboxing = false;
29+
jest.spyOn(Container, 'get').mockReturnValue(securityConfig);
30+
});
31+
afterAll(() => {
32+
jest.restoreAllMocks();
33+
});
2234
it('should replace ampersands and double quotes in HTML', () => {
2335
const html = '<div class="test">Content & more</div>';
2436
expect(sandboxHtmlResponse(html)).toMatchSnapshot();
@@ -305,6 +317,13 @@ describe('createHtmlSandboxTransformStream', () => {
305317
});
306318

307319
describe('sandboxHtmlResponse > not string types', () => {
320+
beforeAll(() => {
321+
securityConfig.disableIframeSandboxing = false;
322+
jest.spyOn(Container, 'get').mockReturnValue(securityConfig);
323+
});
324+
afterAll(() => {
325+
jest.restoreAllMocks();
326+
});
308327
it('should not throw if data is number', () => {
309328
const data = 123;
310329
expect(() => sandboxHtmlResponse(data)).not.toThrow();
@@ -320,3 +339,37 @@ describe('sandboxHtmlResponse > not string types', () => {
320339
expect(() => sandboxHtmlResponse(data)).not.toThrow();
321340
});
322341
});
342+
343+
describe('sandboxHtmlResponse > sandboxing disabled', () => {
344+
beforeAll(() => {
345+
securityConfig.disableIframeSandboxing = true;
346+
jest.spyOn(Container, 'get').mockReturnValue(securityConfig);
347+
});
348+
afterAll(() => {
349+
jest.restoreAllMocks();
350+
});
351+
it('should return unchanged number data', () => {
352+
const data = 123;
353+
expect(sandboxHtmlResponse(data)).toEqual(data);
354+
});
355+
356+
it('should return unchanged object data', () => {
357+
const data = {};
358+
expect(sandboxHtmlResponse(data)).toEqual(data);
359+
});
360+
361+
it('should return unchanged boolean data', () => {
362+
const data = true;
363+
expect(sandboxHtmlResponse(data)).toEqual(data);
364+
});
365+
366+
it('should return unchanged text data', () => {
367+
const data = 'string data';
368+
expect(sandboxHtmlResponse(data)).toEqual(data);
369+
});
370+
371+
it('should return unchanged html data', () => {
372+
const data = '<p>html data</p>';
373+
expect(sandboxHtmlResponse(data)).toEqual(data);
374+
});
375+
});

packages/core/src/html-sandbox.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
import { SecurityConfig } from '@n8n/config';
2+
import { Container } from '@n8n/di';
13
import { JSDOM } from 'jsdom';
24
import type { TransformCallback } from 'stream';
35
import { Transform } from 'stream';
46

7+
export const isIframeSandboxDisabled = () => {
8+
return Container.get(SecurityConfig).disableIframeSandboxing;
9+
};
10+
511
/**
612
* Checks if the given string contains HTML.
713
*/
@@ -20,22 +26,23 @@ export const hasHtml = (str: string) => {
2026
* Sandboxes the HTML response to prevent possible exploitation, if the data has HTML.
2127
* If the data does not have HTML, it will be returned as is.
2228
* Otherwise, it embeds the response in an iframe to make sure the HTML has a different origin.
29+
* Env var `N8N_INSECURE_DISABLE_WEBHOOK_IFRAME_SANDBOX` can be used, in this case sandboxing is disabled.
2330
*
2431
* @param data - The data to sandbox.
2532
* @param forceSandbox - Whether to force sandboxing even if the data does not contain HTML.
2633
* @returns The sandboxed HTML response.
2734
*/
2835
export const sandboxHtmlResponse = <T>(data: T, forceSandbox = false) => {
36+
if (isIframeSandboxDisabled()) return data;
37+
2938
let text;
3039
if (typeof data !== 'string') {
3140
text = JSON.stringify(data);
3241
} else {
3342
text = data;
3443
}
3544

36-
if (!forceSandbox && !hasHtml(text)) {
37-
return text;
38-
}
45+
if (!forceSandbox && !hasHtml(text)) return text;
3946

4047
// Escape & and " as mentioned in the spec:
4148
// https://html.spec.whatwg.org/multipage/iframe-embed-object.html#the-iframe-element

packages/nodes-base/nodes/RespondToWebhook/utils/binary.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isHtmlRenderedContentType, sandboxHtmlResponse } from 'n8n-core';
1+
import { isHtmlRenderedContentType, sandboxHtmlResponse, isIframeSandboxDisabled } from 'n8n-core';
22
import type { IBinaryData, IDataObject, IN8nHttpResponse } from 'n8n-workflow';
33
import { BINARY_ENCODING } from 'n8n-workflow';
44
import type { Readable } from 'stream';
@@ -16,9 +16,15 @@ const setContentLength = (responseBody: IN8nHttpResponse | Readable, headers: ID
1616
*/
1717
export const getBinaryResponse = (binaryData: IBinaryData, headers: IDataObject) => {
1818
const contentType = headers['content-type'] as string;
19-
const shouldSandboxResponseData =
20-
isHtmlRenderedContentType(binaryData.mimeType) ||
21-
(contentType && isHtmlRenderedContentType(contentType));
19+
20+
let shouldSandboxResponseData;
21+
if (isIframeSandboxDisabled()) {
22+
shouldSandboxResponseData = false;
23+
} else {
24+
shouldSandboxResponseData =
25+
isHtmlRenderedContentType(binaryData.mimeType) ||
26+
(contentType && isHtmlRenderedContentType(contentType));
27+
}
2228

2329
let responseBody: IN8nHttpResponse | Readable;
2430

0 commit comments

Comments
 (0)