mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
fix(core)!: Use CSP header to sandbox html webhooks instead of iframe (#18602)
This commit is contained in:
@@ -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