diff --git a/packages/@n8n/config/src/configs/security.config.ts b/packages/@n8n/config/src/configs/security.config.ts index e9cac862ed..ee42440300 100644 --- a/packages/@n8n/config/src/configs/security.config.ts +++ b/packages/@n8n/config/src/configs/security.config.ts @@ -38,4 +38,8 @@ export class SecurityConfig { */ @Env('N8N_CONTENT_SECURITY_POLICY_REPORT_ONLY') contentSecurityPolicyReportOnly: boolean = false; + + /** Whether to disable iframe sandboxing for webhooks */ + @Env('N8N_INSECURE_DISABLE_WEBHOOK_IFRAME_SANDBOX') + disableIframeSandboxing: boolean = false; } diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 379e684443..8f2008a711 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -303,6 +303,7 @@ describe('GlobalConfig', () => { daysAbandonedWorkflow: 90, contentSecurityPolicy: '{}', contentSecurityPolicyReportOnly: false, + disableIframeSandboxing: false, }, executions: { pruneData: true, diff --git a/packages/cli/src/webhooks/webhook-request-handler.ts b/packages/cli/src/webhooks/webhook-request-handler.ts index dd2eb671ad..97c692d388 100644 --- a/packages/cli/src/webhooks/webhook-request-handler.ts +++ b/packages/cli/src/webhooks/webhook-request-handler.ts @@ -151,7 +151,7 @@ class WebhookRequestHandler { } else { const needsSandbox = contentType && isHtmlRenderedContentType(contentType); if (needsSandbox) { - res.send(sandboxHtmlResponse(JSON.stringify(body))); + res.send(sandboxHtmlResponse(body)); } else { res.json(body); } diff --git a/packages/core/src/__tests__/html-sandbox.test.ts b/packages/core/src/__tests__/html-sandbox.test.ts index 27813399d6..d93a9e8bf4 100644 --- a/packages/core/src/__tests__/html-sandbox.test.ts +++ b/packages/core/src/__tests__/html-sandbox.test.ts @@ -1,3 +1,6 @@ +import type { SecurityConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; +import { mock } from 'jest-mock-extended'; import { Readable } from 'stream'; import { @@ -18,7 +21,16 @@ async function consumeStreamToString(stream: NodeJS.ReadableStream): Promise(); + describe('sandboxHtmlResponse', () => { + beforeAll(() => { + securityConfig.disableIframeSandboxing = false; + jest.spyOn(Container, 'get').mockReturnValue(securityConfig); + }); + afterAll(() => { + jest.restoreAllMocks(); + }); it('should replace ampersands and double quotes in HTML', () => { const html = '
Content & more
'; expect(sandboxHtmlResponse(html)).toMatchSnapshot(); @@ -305,6 +317,13 @@ describe('createHtmlSandboxTransformStream', () => { }); describe('sandboxHtmlResponse > not string types', () => { + beforeAll(() => { + securityConfig.disableIframeSandboxing = false; + jest.spyOn(Container, 'get').mockReturnValue(securityConfig); + }); + afterAll(() => { + jest.restoreAllMocks(); + }); it('should not throw if data is number', () => { const data = 123; expect(() => sandboxHtmlResponse(data)).not.toThrow(); @@ -320,3 +339,37 @@ describe('sandboxHtmlResponse > not string types', () => { expect(() => sandboxHtmlResponse(data)).not.toThrow(); }); }); + +describe('sandboxHtmlResponse > sandboxing disabled', () => { + beforeAll(() => { + securityConfig.disableIframeSandboxing = true; + jest.spyOn(Container, 'get').mockReturnValue(securityConfig); + }); + afterAll(() => { + jest.restoreAllMocks(); + }); + it('should return unchanged number data', () => { + const data = 123; + expect(sandboxHtmlResponse(data)).toEqual(data); + }); + + it('should return unchanged object data', () => { + const data = {}; + expect(sandboxHtmlResponse(data)).toEqual(data); + }); + + it('should return unchanged boolean data', () => { + const data = true; + expect(sandboxHtmlResponse(data)).toEqual(data); + }); + + it('should return unchanged text data', () => { + const data = 'string data'; + expect(sandboxHtmlResponse(data)).toEqual(data); + }); + + it('should return unchanged html data', () => { + const data = '

html data

'; + expect(sandboxHtmlResponse(data)).toEqual(data); + }); +}); diff --git a/packages/core/src/html-sandbox.ts b/packages/core/src/html-sandbox.ts index 9498dd62a1..db55546b3f 100644 --- a/packages/core/src/html-sandbox.ts +++ b/packages/core/src/html-sandbox.ts @@ -1,7 +1,13 @@ +import { SecurityConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; import { JSDOM } from 'jsdom'; import type { TransformCallback } from 'stream'; import { Transform } from 'stream'; +export const isIframeSandboxDisabled = () => { + return Container.get(SecurityConfig).disableIframeSandboxing; +}; + /** * Checks if the given string contains HTML. */ @@ -20,12 +26,15 @@ export const hasHtml = (str: string) => { * Sandboxes the HTML response to prevent possible exploitation, if the data has HTML. * If the data does not have HTML, it will be returned as is. * Otherwise, it embeds the response in an iframe to make sure the HTML has a different origin. + * Env var `N8N_INSECURE_DISABLE_WEBHOOK_IFRAME_SANDBOX` can be used, in this case sandboxing is disabled. * * @param data - The data to sandbox. * @param forceSandbox - Whether to force sandboxing even if the data does not contain HTML. * @returns The sandboxed HTML response. */ export const sandboxHtmlResponse = (data: T, forceSandbox = false) => { + if (isIframeSandboxDisabled()) return data; + let text; if (typeof data !== 'string') { text = JSON.stringify(data); @@ -33,9 +42,7 @@ export const sandboxHtmlResponse = (data: T, forceSandbox = false) => { text = data; } - if (!forceSandbox && !hasHtml(text)) { - return text; - } + if (!forceSandbox && !hasHtml(text)) return text; // Escape & and " as mentioned in the spec: // https://html.spec.whatwg.org/multipage/iframe-embed-object.html#the-iframe-element diff --git a/packages/nodes-base/nodes/RespondToWebhook/utils/binary.ts b/packages/nodes-base/nodes/RespondToWebhook/utils/binary.ts index 5da549c667..97ba3c02a8 100644 --- a/packages/nodes-base/nodes/RespondToWebhook/utils/binary.ts +++ b/packages/nodes-base/nodes/RespondToWebhook/utils/binary.ts @@ -1,4 +1,4 @@ -import { isHtmlRenderedContentType, sandboxHtmlResponse } from 'n8n-core'; +import { isHtmlRenderedContentType, sandboxHtmlResponse, isIframeSandboxDisabled } from 'n8n-core'; import type { IBinaryData, IDataObject, IN8nHttpResponse } from 'n8n-workflow'; import { BINARY_ENCODING } from 'n8n-workflow'; import type { Readable } from 'stream'; @@ -16,9 +16,15 @@ const setContentLength = (responseBody: IN8nHttpResponse | Readable, headers: ID */ export const getBinaryResponse = (binaryData: IBinaryData, headers: IDataObject) => { const contentType = headers['content-type'] as string; - const shouldSandboxResponseData = - isHtmlRenderedContentType(binaryData.mimeType) || - (contentType && isHtmlRenderedContentType(contentType)); + + let shouldSandboxResponseData; + if (isIframeSandboxDisabled()) { + shouldSandboxResponseData = false; + } else { + shouldSandboxResponseData = + isHtmlRenderedContentType(binaryData.mimeType) || + (contentType && isHtmlRenderedContentType(contentType)); + } let responseBody: IN8nHttpResponse | Readable;