mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
fix(core)!: Use CSP header to sandbox html webhooks instead of iframe (#18602)
This commit is contained in:
@@ -39,7 +39,10 @@ export class SecurityConfig {
|
||||
@Env('N8N_CONTENT_SECURITY_POLICY_REPORT_ONLY')
|
||||
contentSecurityPolicyReportOnly: boolean = false;
|
||||
|
||||
/** Whether to disable iframe sandboxing for webhooks */
|
||||
/**
|
||||
* Whether to disable HTML sandboxing for webhooks. The sandboxing mechanism uses CSP headers now,
|
||||
* but the name is kept for backwards compatibility.
|
||||
*/
|
||||
@Env('N8N_INSECURE_DISABLE_WEBHOOK_IFRAME_SANDBOX')
|
||||
disableIframeSandboxing: boolean = false;
|
||||
disableWebhookHtmlSandboxing: boolean = false;
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ describe('GlobalConfig', () => {
|
||||
daysAbandonedWorkflow: 90,
|
||||
contentSecurityPolicy: '{}',
|
||||
contentSecurityPolicyReportOnly: false,
|
||||
disableIframeSandboxing: false,
|
||||
disableWebhookHtmlSandboxing: false,
|
||||
},
|
||||
executions: {
|
||||
pruneData: true,
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
This list shows all the versions which include breaking changes and how to upgrade.
|
||||
|
||||
# 1.109.0
|
||||
|
||||
### What changed?
|
||||
|
||||
Webhook HTML responses were sandboxed to an iframe starting from 1.103.1 due to security. The sandboxing mechanism is now changed to use `Content-Security-Policy` header instead of an `iframe`. The security guarantees stay the same, but the mechanism is less breaking.
|
||||
|
||||
### When is action necessary?
|
||||
|
||||
If you have workflows that return HTML responses from `Webhook Trigger` node or `Respond to Webhook` node.
|
||||
|
||||
# 1.107.0
|
||||
|
||||
## What changed?
|
||||
|
||||
@@ -2,15 +2,13 @@ import { Logger } from '@n8n/backend-common';
|
||||
import { Container } from '@n8n/di';
|
||||
import type express from 'express';
|
||||
import {
|
||||
isWebhookHtmlSandboxingDisabled,
|
||||
getWebhookSandboxCSP,
|
||||
isHtmlRenderedContentType,
|
||||
sandboxHtmlResponse,
|
||||
createHtmlSandboxTransformStream,
|
||||
} from 'n8n-core';
|
||||
import { ensureError, type IHttpRequestMethods } from 'n8n-workflow';
|
||||
import { finished } from 'stream/promises';
|
||||
|
||||
import { WebhookService } from './webhook.service';
|
||||
|
||||
import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error';
|
||||
import * as ResponseHelper from '@/response-helper';
|
||||
import type {
|
||||
@@ -24,6 +22,7 @@ import {
|
||||
isWebhookResponse,
|
||||
isWebhookStreamResponse,
|
||||
} from '@/webhooks/webhook-response';
|
||||
import { WebhookService } from '@/webhooks/webhook.service';
|
||||
import type {
|
||||
IWebhookManager,
|
||||
WebhookOptionsRequest,
|
||||
@@ -126,12 +125,8 @@ class WebhookRequestHandler {
|
||||
this.setResponseStatus(res, code);
|
||||
this.setResponseHeaders(res, headers);
|
||||
|
||||
const contentType = res.getHeader('content-type') as string | undefined;
|
||||
const needsSandbox = contentType && isHtmlRenderedContentType(contentType);
|
||||
|
||||
const streamToSend = needsSandbox ? stream.pipe(createHtmlSandboxTransformStream()) : stream;
|
||||
streamToSend.pipe(res, { end: false });
|
||||
await finished(streamToSend);
|
||||
stream.pipe(res, { end: false });
|
||||
await finished(stream);
|
||||
|
||||
process.nextTick(() => res.end());
|
||||
}
|
||||
@@ -142,19 +137,10 @@ class WebhookRequestHandler {
|
||||
this.setResponseStatus(res, code);
|
||||
this.setResponseHeaders(res, headers);
|
||||
|
||||
const contentType = res.getHeader('content-type') as string | undefined;
|
||||
|
||||
if (typeof body === 'string') {
|
||||
const needsSandbox = !contentType || isHtmlRenderedContentType(contentType);
|
||||
const bodyToSend = needsSandbox ? sandboxHtmlResponse(body) : body;
|
||||
res.send(bodyToSend);
|
||||
res.send(body);
|
||||
} else {
|
||||
const needsSandbox = contentType && isHtmlRenderedContentType(contentType);
|
||||
if (needsSandbox) {
|
||||
res.send(sandboxHtmlResponse(body));
|
||||
} else {
|
||||
res.json(body);
|
||||
}
|
||||
res.json(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +156,13 @@ class WebhookRequestHandler {
|
||||
res.setHeader(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = res.getHeader('content-type') as string | undefined;
|
||||
const needsSandbox = !contentType || isHtmlRenderedContentType(contentType);
|
||||
|
||||
if (needsSandbox && !isWebhookHtmlSandboxingDisabled()) {
|
||||
res.setHeader('Content-Security-Policy', getWebhookSandboxCSP());
|
||||
}
|
||||
}
|
||||
|
||||
private async setupCorsHeaders(
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`sandboxHtmlResponse should always sandbox if forceSandbox is true 1`] = `
|
||||
"<iframe srcdoc="Hello World" sandbox="allow-scripts allow-forms allow-popups allow-modals allow-orientation-lock allow-pointer-lock allow-presentation allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"
|
||||
style="position:fixed; top:0; left:0; width:100vw; height:100vh; border:none; overflow:auto;"
|
||||
allowtransparency="true"></iframe>"
|
||||
`;
|
||||
|
||||
exports[`sandboxHtmlResponse should handle HTML with special characters 1`] = `
|
||||
"<iframe srcdoc="<p>Special characters: <>&"'</p>" sandbox="allow-scripts allow-forms allow-popups allow-modals allow-orientation-lock allow-pointer-lock allow-presentation allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"
|
||||
style="position:fixed; top:0; left:0; width:100vw; height:100vh; border:none; overflow:auto;"
|
||||
allowtransparency="true"></iframe>"
|
||||
`;
|
||||
|
||||
exports[`sandboxHtmlResponse should replace ampersands and double quotes in HTML 1`] = `
|
||||
"<iframe srcdoc="<div class="test">Content & more</div>" sandbox="allow-scripts allow-forms allow-popups allow-modals allow-orientation-lock allow-pointer-lock allow-presentation allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"
|
||||
style="position:fixed; top:0; left:0; width:100vw; height:100vh; border:none; overflow:auto;"
|
||||
allowtransparency="true"></iframe>"
|
||||
`;
|
||||
|
||||
exports[`sandboxHtmlResponse should sandbox even with no <body> tag 1`] = `
|
||||
"<iframe srcdoc="<html><head><title>Test</title><script>alert("Hello")</script></head></html>" sandbox="allow-scripts allow-forms allow-popups allow-modals allow-orientation-lock allow-pointer-lock allow-presentation allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"
|
||||
style="position:fixed; top:0; left:0; width:100vw; height:100vh; border:none; overflow:auto;"
|
||||
allowtransparency="true"></iframe>"
|
||||
`;
|
||||
|
||||
exports[`sandboxHtmlResponse should sandbox when outside <body> and <head> tags 1`] = `
|
||||
"<iframe srcdoc="<html><head><title>Test</title></head><body></body><script>alert("Hello")</script></html>" sandbox="allow-scripts allow-forms allow-popups allow-modals allow-orientation-lock allow-pointer-lock allow-presentation allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"
|
||||
style="position:fixed; top:0; left:0; width:100vw; height:100vh; border:none; overflow:auto;"
|
||||
allowtransparency="true"></iframe>"
|
||||
`;
|
||||
|
||||
exports[`sandboxHtmlResponse should sandbox when outside <html> tag 1`] = `
|
||||
"<iframe srcdoc="<html><head><title>Test</title></head></html><script>alert("Hello")</script>" sandbox="allow-scripts allow-forms allow-popups allow-modals allow-orientation-lock allow-pointer-lock allow-presentation allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"
|
||||
style="position:fixed; top:0; left:0; width:100vw; height:100vh; border:none; overflow:auto;"
|
||||
allowtransparency="true"></iframe>"
|
||||
`;
|
||||
@@ -1,77 +1,31 @@
|
||||
import type { SecurityConfig } from '@n8n/config';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import {
|
||||
bufferEscapeHtml,
|
||||
createHtmlSandboxTransformStream,
|
||||
hasHtml,
|
||||
isWebhookHtmlSandboxingDisabled,
|
||||
getWebhookSandboxCSP,
|
||||
isHtmlRenderedContentType,
|
||||
sandboxHtmlResponse,
|
||||
} from '../html-sandbox';
|
||||
|
||||
// Utility function to consume a stream into a buffer
|
||||
async function consumeStreamToString(stream: NodeJS.ReadableStream): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks).toString()));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
} from '@/html-sandbox';
|
||||
|
||||
const securityConfig = mock<SecurityConfig>();
|
||||
|
||||
describe('sandboxHtmlResponse', () => {
|
||||
describe('isWebhookHtmlSandboxingDisabled', () => {
|
||||
beforeAll(() => {
|
||||
securityConfig.disableIframeSandboxing = false;
|
||||
jest.spyOn(Container, 'get').mockReturnValue(securityConfig);
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
it('should replace ampersands and double quotes in HTML', () => {
|
||||
const html = '<div class="test">Content & more</div>';
|
||||
expect(sandboxHtmlResponse(html)).toMatchSnapshot();
|
||||
|
||||
it('should return false when sandboxing is enabled', () => {
|
||||
securityConfig.disableWebhookHtmlSandboxing = false;
|
||||
expect(isWebhookHtmlSandboxingDisabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle HTML with special characters', () => {
|
||||
const html = '<p>Special characters: <>&"\'</p>';
|
||||
expect(sandboxHtmlResponse(html)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Hello World', 'Hello World'],
|
||||
['< not html >', '< not html >'],
|
||||
['# Test', '# Test'],
|
||||
['', ''],
|
||||
[123, '123'],
|
||||
[null, 'null'],
|
||||
])('should not sandbox if not html', (data, expected) => {
|
||||
expect(sandboxHtmlResponse(data)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should sandbox even with no <body> tag', () => {
|
||||
const html = '<html><head><title>Test</title><script>alert("Hello")</script></head></html>';
|
||||
expect(sandboxHtmlResponse(html)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should sandbox when outside <body> and <head> tags', () => {
|
||||
const html =
|
||||
'<html><head><title>Test</title></head><body></body><script>alert("Hello")</script></html>';
|
||||
expect(sandboxHtmlResponse(html)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should sandbox when outside <html> tag', () => {
|
||||
const html = '<html><head><title>Test</title></head></html><script>alert("Hello")</script>';
|
||||
expect(sandboxHtmlResponse(html)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should always sandbox if forceSandbox is true', () => {
|
||||
const text = 'Hello World';
|
||||
expect(sandboxHtmlResponse(text, true)).toMatchSnapshot();
|
||||
it('should return true when sandboxing is disabled', () => {
|
||||
securityConfig.disableWebhookHtmlSandboxing = true;
|
||||
expect(isWebhookHtmlSandboxingDisabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,266 +83,16 @@ describe('isHtmlRenderedContentType', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('bufferEscapeHtml', () => {
|
||||
it('should return the same buffer when no escaping is needed', () => {
|
||||
const input = Buffer.from('Hello World', 'utf8');
|
||||
const result = bufferEscapeHtml(input);
|
||||
|
||||
expect(result).toEqual(input);
|
||||
expect(result.toString()).toBe('Hello World');
|
||||
describe('getWebhookSandboxCSP', () => {
|
||||
it('should return correct CSP sandbox directive', () => {
|
||||
const csp = getWebhookSandboxCSP();
|
||||
expect(csp).toBe(
|
||||
'sandbox allow-downloads allow-forms allow-modals allow-orientation-lock allow-pointer-lock allow-popups allow-presentation allow-scripts allow-top-navigation allow-top-navigation-by-user-activation allow-top-navigation-to-custom-protocols',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty buffer', () => {
|
||||
const input = Buffer.alloc(0);
|
||||
const result = bufferEscapeHtml(input);
|
||||
|
||||
expect(result).toEqual(input);
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
describe('should escape special characters', () => {
|
||||
test.each([
|
||||
['&', '&'],
|
||||
['"', '"'],
|
||||
['&"', '&"'],
|
||||
['Hello & World', 'Hello & World'],
|
||||
['Hello "World"', 'Hello "World"'],
|
||||
['Hello & "World"', 'Hello & "World"'],
|
||||
['Hello && World', 'Hello && World'],
|
||||
['Hello ""World""', 'Hello ""World""'],
|
||||
['&"Hello"&"World"&', '&"Hello"&"World"&'],
|
||||
])('should escape %s to %s', (input, expected) => {
|
||||
const buffer = Buffer.from(input, 'utf8');
|
||||
const result = bufferEscapeHtml(buffer);
|
||||
expect(result.toString()).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unicode characters with special characters', () => {
|
||||
const input = Buffer.from('Hello & 世界 "World" & こんにちは', 'utf8');
|
||||
const result = bufferEscapeHtml(input);
|
||||
|
||||
expect(result.toString()).toBe('Hello & 世界 "World" & こんにちは');
|
||||
});
|
||||
|
||||
it('should not modify other special characters', () => {
|
||||
const input = Buffer.from('Hello <World> & "Test"', 'utf8');
|
||||
const result = bufferEscapeHtml(input);
|
||||
|
||||
expect(result.toString()).toBe('Hello <World> & "Test"');
|
||||
expect(result.toString()).toContain('<');
|
||||
expect(result.toString()).toContain('>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createHtmlSandboxTransformStream', () => {
|
||||
const getComparableHtml = (input: Buffer | string) =>
|
||||
sandboxHtmlResponse(input.toString(), true).replace(/\s+/g, ' ');
|
||||
|
||||
it('should wrap single chunk in iframe with proper escaping', async () => {
|
||||
const input = Buffer.from('Hello & "World"', 'utf8');
|
||||
const transform = createHtmlSandboxTransformStream();
|
||||
const readable = new Readable();
|
||||
readable.push(input);
|
||||
readable.push(null);
|
||||
|
||||
const result = await consumeStreamToString(readable.pipe(transform));
|
||||
|
||||
expect(result).toEqual(getComparableHtml(input));
|
||||
});
|
||||
|
||||
it('should handle multiple chunks correctly', async () => {
|
||||
const transform = createHtmlSandboxTransformStream();
|
||||
const readable = new Readable();
|
||||
const inputChunks = ['Hello & ', '"World"', ' & Test'];
|
||||
|
||||
for (const chunk of inputChunks) {
|
||||
readable.push(Buffer.from(chunk, 'utf8'));
|
||||
}
|
||||
readable.push(null);
|
||||
|
||||
const result = await consumeStreamToString(readable.pipe(transform));
|
||||
|
||||
expect(result).toEqual(getComparableHtml(inputChunks.join('')));
|
||||
});
|
||||
|
||||
it('should handle empty input', async () => {
|
||||
const transform = createHtmlSandboxTransformStream();
|
||||
const readable = new Readable();
|
||||
readable.push(null);
|
||||
|
||||
const result = await consumeStreamToString(readable.pipe(transform));
|
||||
|
||||
expect(result).toEqual(getComparableHtml(''));
|
||||
});
|
||||
|
||||
it('should handle empty chunks', async () => {
|
||||
const transform = createHtmlSandboxTransformStream();
|
||||
const readable = new Readable();
|
||||
|
||||
readable.push(Buffer.alloc(0));
|
||||
readable.push(Buffer.from('Hello', 'utf8'));
|
||||
readable.push(Buffer.alloc(0));
|
||||
readable.push(null);
|
||||
|
||||
const result = await consumeStreamToString(readable.pipe(transform));
|
||||
|
||||
expect(result).toEqual(getComparableHtml('Hello'));
|
||||
});
|
||||
|
||||
it('should handle string chunks by converting to buffer', async () => {
|
||||
const transform = createHtmlSandboxTransformStream();
|
||||
const readable = new Readable();
|
||||
readable.push('Hello & "World"');
|
||||
readable.push(null);
|
||||
|
||||
const result = await consumeStreamToString(readable.pipe(transform));
|
||||
|
||||
expect(result).toEqual(getComparableHtml('Hello & "World"'));
|
||||
});
|
||||
|
||||
it('should handle unicode characters correctly', async () => {
|
||||
const input = Buffer.from('Hello & 世界 "World" & こんにちは', 'utf8');
|
||||
const transform = createHtmlSandboxTransformStream();
|
||||
const readable = new Readable();
|
||||
readable.push(input);
|
||||
readable.push(null);
|
||||
|
||||
const result = await consumeStreamToString(readable.pipe(transform));
|
||||
|
||||
expect(result).toEqual(getComparableHtml(input));
|
||||
});
|
||||
|
||||
it('should handle large content in chunks', async () => {
|
||||
const baseString = 'Hello & World "Test" & Another "Quote"';
|
||||
const largeContent = baseString.repeat(100);
|
||||
const transform = createHtmlSandboxTransformStream();
|
||||
const readable = new Readable();
|
||||
|
||||
// Split into chunks
|
||||
const chunkSize = 1000;
|
||||
for (let i = 0; i < largeContent.length; i += chunkSize) {
|
||||
const chunk = largeContent.slice(i, i + chunkSize);
|
||||
readable.push(Buffer.from(chunk, 'utf8'));
|
||||
}
|
||||
readable.push(null);
|
||||
|
||||
const result = await consumeStreamToString(readable.pipe(transform));
|
||||
|
||||
expect(result).toEqual(getComparableHtml(largeContent));
|
||||
});
|
||||
|
||||
it('should handle special HTML characters', async () => {
|
||||
const input = Buffer.from('<div>&"Hello"</div>', 'utf8');
|
||||
const transform = createHtmlSandboxTransformStream();
|
||||
const readable = new Readable();
|
||||
readable.push(input);
|
||||
readable.push(null);
|
||||
|
||||
const result = await consumeStreamToString(readable.pipe(transform));
|
||||
|
||||
expect(result).toEqual(getComparableHtml(input));
|
||||
});
|
||||
|
||||
it('should handle mixed content types', async () => {
|
||||
const transform = createHtmlSandboxTransformStream();
|
||||
const readable = new Readable();
|
||||
|
||||
readable.push(Buffer.from('Hello', 'utf8'));
|
||||
readable.push(' & World');
|
||||
readable.push(Buffer.from(' "Test"', 'utf8'));
|
||||
readable.push(null);
|
||||
|
||||
const result = await consumeStreamToString(readable.pipe(transform));
|
||||
|
||||
expect(result).toEqual(getComparableHtml('Hello & World "Test"'));
|
||||
});
|
||||
|
||||
it('should produce valid HTML structure', async () => {
|
||||
const input = Buffer.from('<h1>Hello & "World"</h1>', 'utf8');
|
||||
const transform = createHtmlSandboxTransformStream();
|
||||
const readable = new Readable();
|
||||
readable.push(input);
|
||||
readable.push(null);
|
||||
|
||||
const result = await consumeStreamToString(readable.pipe(transform));
|
||||
|
||||
expect(result).toEqual(getComparableHtml(input));
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('should not throw if data is object', () => {
|
||||
const data = {};
|
||||
expect(() => sandboxHtmlResponse(data)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw if data is boolean', () => {
|
||||
const data = true;
|
||||
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 = '<p>html data</p>';
|
||||
expect(sandboxHtmlResponse(data)).toEqual(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasHtml', () => {
|
||||
test('returns true for valid HTML', () => {
|
||||
expect(hasHtml('<p>Hello</p>')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for malformed but still HTML-like content', () => {
|
||||
expect(hasHtml('<div><span>Test')).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for plain text', () => {
|
||||
expect(hasHtml('Just a string')).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for empty string', () => {
|
||||
expect(hasHtml('')).toBe(false);
|
||||
it('should not include allow-same-origin', () => {
|
||||
const csp = getWebhookSandboxCSP();
|
||||
expect(csp).not.toContain('allow-same-origin');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,143 +1,15 @@
|
||||
import { SecurityConfig } from '@n8n/config';
|
||||
import { Container } from '@n8n/di';
|
||||
import { ElementType, parseDocument } from 'htmlparser2';
|
||||
import type { TransformCallback } from 'stream';
|
||||
import { Transform } from 'stream';
|
||||
|
||||
export const isIframeSandboxDisabled = () => {
|
||||
return Container.get(SecurityConfig).disableIframeSandboxing;
|
||||
export const isWebhookHtmlSandboxingDisabled = () => {
|
||||
return Container.get(SecurityConfig).disableWebhookHtmlSandboxing;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the given string contains HTML.
|
||||
* Returns the CSP header value that sandboxes the HTML page into a separate origin.
|
||||
*/
|
||||
export const hasHtml = (str: string) => {
|
||||
try {
|
||||
const doc = parseDocument(str);
|
||||
return doc.children.some((node) => node.type === ElementType.Tag);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 = <T>(data: T, forceSandbox = false) => {
|
||||
if (isIframeSandboxDisabled()) return data;
|
||||
|
||||
let text;
|
||||
if (typeof data !== 'string') {
|
||||
text = JSON.stringify(data);
|
||||
} else {
|
||||
text = data;
|
||||
}
|
||||
|
||||
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
|
||||
const escapedHtml = text.replaceAll('&', '&').replaceAll('"', '"');
|
||||
|
||||
return `<iframe srcdoc="${escapedHtml}" sandbox="allow-scripts allow-forms allow-popups allow-modals allow-orientation-lock allow-pointer-lock allow-presentation allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"
|
||||
style="position:fixed; top:0; left:0; width:100vw; height:100vh; border:none; overflow:auto;"
|
||||
allowtransparency="true"></iframe>`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts ampersands and double quotes in a buffer to their HTML entities.
|
||||
* Does double pass on the buffer to avoid multiple allocations.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const input = Buffer.from('Hello & "World"', 'utf8');
|
||||
* const result = bufferEscapeHtml(input);
|
||||
* console.log(result.toString()); // 'Hello & "World"'
|
||||
* ```
|
||||
*/
|
||||
export const bufferEscapeHtml = (input: Buffer) => {
|
||||
const ampersand = Buffer.from('&', 'utf8').readUInt8(0);
|
||||
const escapedAmpersand = Buffer.from('&', 'utf8');
|
||||
const doublequote = Buffer.from('"', 'utf8').readUInt8(0);
|
||||
const escapedDoublequote = Buffer.from('"', 'utf8');
|
||||
|
||||
let ampersandCount = 0;
|
||||
let doublequoteCount = 0;
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
if (input[i] === ampersand) ampersandCount++;
|
||||
else if (input[i] === doublequote) doublequoteCount++;
|
||||
}
|
||||
|
||||
if (ampersandCount === 0 && doublequoteCount === 0) return Buffer.from(input);
|
||||
|
||||
const resultLength =
|
||||
input.length +
|
||||
ampersandCount * (escapedAmpersand.length - 1) +
|
||||
doublequoteCount * (escapedDoublequote.length - 1);
|
||||
const output = Buffer.alloc(resultLength);
|
||||
let writeOffset = 0;
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
if (input[i] === ampersand) {
|
||||
escapedAmpersand.copy(output, writeOffset);
|
||||
writeOffset += escapedAmpersand.length;
|
||||
} else if (input[i] === doublequote) {
|
||||
escapedDoublequote.copy(output, writeOffset);
|
||||
writeOffset += escapedDoublequote.length;
|
||||
} else {
|
||||
output[writeOffset++] = input[i];
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a transform stream that sandboxes HTML content by wrapping it in an iframe.
|
||||
* This is the streaming equivalent of sandboxHtmlResponse.
|
||||
*/
|
||||
export const createHtmlSandboxTransformStream = () => {
|
||||
let isFirstChunk = true;
|
||||
|
||||
const prefix = Buffer.from('<iframe srcdoc="', 'utf8');
|
||||
const suffix = Buffer.from(
|
||||
'" sandbox="allow-scripts allow-forms allow-popups allow-modals allow-orientation-lock allow-pointer-lock allow-presentation allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" style="position:fixed; top:0; left:0; width:100vw; height:100vh; border:none; overflow:auto;" allowtransparency="true"></iframe>',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
return new Transform({
|
||||
transform(chunk: Buffer, encoding: string, done: TransformCallback) {
|
||||
try {
|
||||
chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding as BufferEncoding);
|
||||
const escapedChunk = bufferEscapeHtml(chunk);
|
||||
const transformedChunk = isFirstChunk
|
||||
? Buffer.concat([prefix, escapedChunk])
|
||||
: escapedChunk;
|
||||
isFirstChunk = false;
|
||||
|
||||
done(null, transformedChunk);
|
||||
} catch (error) {
|
||||
done(error as Error);
|
||||
}
|
||||
},
|
||||
|
||||
flush(done: TransformCallback) {
|
||||
try {
|
||||
this.push(isFirstChunk ? Buffer.concat([prefix, suffix]) : suffix);
|
||||
done();
|
||||
} catch (error) {
|
||||
done(error as Error);
|
||||
}
|
||||
},
|
||||
});
|
||||
export const getWebhookSandboxCSP = (): string => {
|
||||
return 'sandbox allow-downloads allow-forms allow-modals allow-orientation-lock allow-pointer-lock allow-popups allow-presentation allow-scripts allow-top-navigation allow-top-navigation-by-user-activation allow-top-navigation-to-custom-protocols';
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import set from 'lodash/set';
|
||||
import { isHtmlRenderedContentType, sandboxHtmlResponse } from 'n8n-core';
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
@@ -402,9 +401,6 @@ export class RespondToWebhook implements INodeType {
|
||||
}
|
||||
}
|
||||
|
||||
const hasHtmlContentType =
|
||||
headers['content-type'] && isHtmlRenderedContentType(headers['content-type'] as string);
|
||||
|
||||
let statusCode = (options.responseCode as number) || 200;
|
||||
let responseBody: IN8nHttpResponse | Readable;
|
||||
if (respondWith === 'json') {
|
||||
@@ -480,13 +476,9 @@ export class RespondToWebhook implements INodeType {
|
||||
this.sendChunk('end', 0);
|
||||
}
|
||||
} else if (respondWith === 'text') {
|
||||
// If a user doesn't set the content-type header and uses html, the html can still be rendered on the browser
|
||||
const rawBody = this.getNodeParameter('responseBody', 0) as string;
|
||||
if (hasHtmlContentType || !headers['content-type']) {
|
||||
responseBody = sandboxHtmlResponse(rawBody);
|
||||
} else {
|
||||
responseBody = rawBody;
|
||||
}
|
||||
responseBody = rawBody;
|
||||
|
||||
// Send the raw body to the stream
|
||||
if (shouldStream) {
|
||||
this.sendChunk('begin', 0);
|
||||
@@ -564,15 +556,6 @@ export class RespondToWebhook implements INodeType {
|
||||
return [[{ json: {}, sendMessage: message }]];
|
||||
}
|
||||
|
||||
if (
|
||||
hasHtmlContentType &&
|
||||
respondWith !== 'text' &&
|
||||
respondWith !== 'binary' &&
|
||||
responseBody
|
||||
) {
|
||||
responseBody = sandboxHtmlResponse(JSON.stringify(responseBody as string));
|
||||
}
|
||||
|
||||
response = {
|
||||
body: responseBody,
|
||||
headers,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DeepMockProxy } from 'jest-mock-extended';
|
||||
import { mock, mockDeep } from 'jest-mock-extended';
|
||||
import { constructExecutionMetaData, sandboxHtmlResponse } from 'n8n-core';
|
||||
import { constructExecutionMetaData } from 'n8n-core';
|
||||
import {
|
||||
BINARY_ENCODING,
|
||||
WAIT_NODE_TYPE,
|
||||
@@ -236,7 +236,7 @@ describe('RespondToWebhook Node', () => {
|
||||
|
||||
await expect(respondToWebhook.execute.call(mockExecuteFunctions)).resolves.not.toThrow();
|
||||
expect(mockExecuteFunctions.sendResponse).toHaveBeenCalledWith({
|
||||
body: sandboxHtmlResponse('responseBody'),
|
||||
body: 'responseBody',
|
||||
headers: {},
|
||||
statusCode: 200,
|
||||
});
|
||||
@@ -336,74 +336,6 @@ describe('RespondToWebhook Node', () => {
|
||||
expect(mockExecuteFunctions.sendResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('HTML content sandboxing', () => {
|
||||
it('should sandbox HTML content for json response with HTML content-type', async () => {
|
||||
const inputItems = [
|
||||
{ json: { index: 0, input: true } },
|
||||
{ json: { index: 1, input: true } },
|
||||
];
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputItems);
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.1 }));
|
||||
mockExecuteFunctions.getParentNodes.mockReturnValue([
|
||||
mock<NodeTypeAndVersion>({ type: WAIT_NODE_TYPE }),
|
||||
]);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName) => {
|
||||
if (paramName === 'respondWith') return 'allIncomingItems';
|
||||
if (paramName === 'options')
|
||||
return {
|
||||
responseHeaders: {
|
||||
entries: [{ name: 'content-type', value: 'application/xhtml+xml' }],
|
||||
},
|
||||
};
|
||||
});
|
||||
mockExecuteFunctions.sendResponse.mockReturnValue();
|
||||
|
||||
const result = await respondToWebhook.execute.call(mockExecuteFunctions);
|
||||
expect(mockExecuteFunctions.sendResponse).toHaveBeenCalledWith({
|
||||
body: sandboxHtmlResponse(JSON.stringify(inputItems.map((item) => item.json))),
|
||||
headers: { 'content-type': 'application/xhtml+xml' },
|
||||
statusCode: 200,
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveLength(2);
|
||||
expect(result[0]).toEqual(inputItems);
|
||||
});
|
||||
|
||||
it('should NOT sandbox HTML content for non-HTML content-type', async () => {
|
||||
const inputItems = [
|
||||
{ json: { index: 0, input: true } },
|
||||
{ json: { index: 1, input: true } },
|
||||
];
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputItems);
|
||||
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.1 }));
|
||||
mockExecuteFunctions.getParentNodes.mockReturnValue([
|
||||
mock<NodeTypeAndVersion>({ type: WAIT_NODE_TYPE }),
|
||||
]);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((paramName) => {
|
||||
if (paramName === 'respondWith') return 'allIncomingItems';
|
||||
if (paramName === 'options') return {};
|
||||
});
|
||||
mockExecuteFunctions.sendResponse.mockReturnValue();
|
||||
|
||||
const result = await respondToWebhook.execute.call(mockExecuteFunctions);
|
||||
expect(mockExecuteFunctions.sendResponse).toHaveBeenCalledWith({
|
||||
body: inputItems.map((item) => item.json),
|
||||
headers: {},
|
||||
statusCode: 200,
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveLength(2);
|
||||
expect(result[0]).toEqual(inputItems);
|
||||
|
||||
await expect(respondToWebhook.execute.call(mockExecuteFunctions)).resolves.not.toThrow();
|
||||
expect(mockExecuteFunctions.sendResponse).toHaveBeenCalledWith({
|
||||
body: inputItems.map((item) => item.json),
|
||||
headers: {},
|
||||
statusCode: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should have two outputs in version 1.3', async () => {
|
||||
const inputItems = [{ json: { index: 0, input: true } }, { json: { index: 1, input: true } }];
|
||||
mockExecuteFunctions.getInputData.mockReturnValue(inputItems);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { sandboxHtmlResponse } from 'n8n-core';
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
import { BINARY_ENCODING } from 'n8n-workflow';
|
||||
|
||||
import { getBinaryResponse } from '../utils/binary';
|
||||
|
||||
describe('getBinaryResponse', () => {
|
||||
it('returns sanitized HTML when binaryData.id is present and mimeType is text/html', () => {
|
||||
it('returns { binaryData } when binaryData.id is present', () => {
|
||||
const binaryData = {
|
||||
id: '123',
|
||||
data: '<h1>Hello</h1>',
|
||||
@@ -15,7 +14,7 @@ describe('getBinaryResponse', () => {
|
||||
|
||||
const result = getBinaryResponse(binaryData, headers);
|
||||
|
||||
expect(result).toBe(sandboxHtmlResponse(binaryData.data));
|
||||
expect(result).toEqual({ binaryData });
|
||||
expect(headers['content-type']).toBe('text/html');
|
||||
});
|
||||
|
||||
@@ -33,7 +32,7 @@ describe('getBinaryResponse', () => {
|
||||
expect(headers['content-type']).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
it('returns sanitized HTML when binaryData.id is not present and mimeType is text/html', () => {
|
||||
it('returns Buffer when binaryData.id is not present', () => {
|
||||
const binaryData = {
|
||||
data: '<h1>Hello</h1>',
|
||||
mimeType: 'text/html',
|
||||
@@ -42,9 +41,8 @@ describe('getBinaryResponse', () => {
|
||||
|
||||
const result = getBinaryResponse(binaryData, headers);
|
||||
|
||||
expect(result).toBe(
|
||||
sandboxHtmlResponse(Buffer.from(binaryData.data, BINARY_ENCODING).toString()),
|
||||
);
|
||||
expect(Buffer.isBuffer(result)).toBe(true);
|
||||
expect(result.toString()).toBe(Buffer.from(binaryData.data, BINARY_ENCODING).toString());
|
||||
expect(headers['content-type']).toBe('text/html');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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';
|
||||
@@ -15,30 +14,13 @@ const setContentLength = (responseBody: IN8nHttpResponse | Readable, headers: ID
|
||||
* Returns a response body for a binary data and sets the content-type header.
|
||||
*/
|
||||
export const getBinaryResponse = (binaryData: IBinaryData, headers: IDataObject) => {
|
||||
const contentType = headers['content-type'] as string;
|
||||
|
||||
let shouldSandboxResponseData;
|
||||
if (isIframeSandboxDisabled()) {
|
||||
shouldSandboxResponseData = false;
|
||||
} else {
|
||||
shouldSandboxResponseData =
|
||||
isHtmlRenderedContentType(binaryData.mimeType) ||
|
||||
(contentType && isHtmlRenderedContentType(contentType));
|
||||
}
|
||||
|
||||
let responseBody: IN8nHttpResponse | Readable;
|
||||
|
||||
if (binaryData.id) {
|
||||
responseBody = shouldSandboxResponseData
|
||||
? sandboxHtmlResponse(binaryData.data)
|
||||
: { binaryData };
|
||||
responseBody = { binaryData };
|
||||
} else {
|
||||
const responseBuffer = Buffer.from(binaryData.data, BINARY_ENCODING);
|
||||
|
||||
responseBody = shouldSandboxResponseData
|
||||
? sandboxHtmlResponse(responseBuffer.toString())
|
||||
: responseBuffer;
|
||||
|
||||
responseBody = responseBuffer;
|
||||
setContentLength(responseBody, headers);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user