mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
fix(Webhook Trigger Node)!: Change html responses to be embedded an iframe (#17312)
Co-authored-by: Dana Lee <dana@n8n.io>
This commit is contained in:
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
This list shows all the versions which include breaking changes and how to upgrade.
|
This list shows all the versions which include breaking changes and how to upgrade.
|
||||||
|
|
||||||
|
## 1.103.0
|
||||||
|
|
||||||
|
### What changed?
|
||||||
|
|
||||||
|
We will no longer be allowing users to use `responseData` within the Webhook node since this is now sandboxed in an iframe, which may break workflows relying on browser APIs like `localStorage` and `fetch` from within custom code.
|
||||||
|
|
||||||
|
### When is action necessary?
|
||||||
|
|
||||||
|
If your workflow is using the Webhook node and uses JavaScript in `responseData` to make `fetch` calls or access `localStorage`, you may need to refactor it due to the new iframe sandboxing.
|
||||||
|
|
||||||
## 1.102.0
|
## 1.102.0
|
||||||
|
|
||||||
### What changed?
|
### What changed?
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { finished } from 'stream/promises';
|
|||||||
import {
|
import {
|
||||||
autoDetectResponseMode,
|
autoDetectResponseMode,
|
||||||
handleFormRedirectionCase,
|
handleFormRedirectionCase,
|
||||||
getResponseOnReceived,
|
|
||||||
setupResponseNodePromise,
|
setupResponseNodePromise,
|
||||||
prepareExecutionData,
|
prepareExecutionData,
|
||||||
} from '../webhook-helpers';
|
} from '../webhook-helpers';
|
||||||
@@ -140,49 +139,6 @@ describe('handleFormRedirectionCase', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getResponseOnReceived', () => {
|
|
||||||
const responseCode = 200;
|
|
||||||
const webhookResultData = mock<IWebhookResponseData>();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return response with no data when responseData is "noData"', () => {
|
|
||||||
const callbackData = getResponseOnReceived('noData', webhookResultData, responseCode);
|
|
||||||
|
|
||||||
expect(callbackData).toEqual({ responseCode });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return response with responseData when it is defined', () => {
|
|
||||||
const responseData = JSON.stringify({ foo: 'bar' });
|
|
||||||
|
|
||||||
const callbackData = getResponseOnReceived(responseData, webhookResultData, responseCode);
|
|
||||||
|
|
||||||
expect(callbackData).toEqual({ data: responseData, responseCode });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return response with webhookResponse when responseData is falsy but webhookResponse exists', () => {
|
|
||||||
const webhookResponse = { success: true };
|
|
||||||
webhookResultData.webhookResponse = webhookResponse;
|
|
||||||
|
|
||||||
const callbackData = getResponseOnReceived(undefined, webhookResultData, responseCode);
|
|
||||||
|
|
||||||
expect(callbackData).toEqual({ data: webhookResponse, responseCode });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return default response message when responseData and webhookResponse are falsy', () => {
|
|
||||||
webhookResultData.webhookResponse = undefined;
|
|
||||||
|
|
||||||
const callbackData = getResponseOnReceived(undefined, webhookResultData, responseCode);
|
|
||||||
|
|
||||||
expect(callbackData).toEqual({
|
|
||||||
data: { message: 'Workflow was started' },
|
|
||||||
responseCode,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('setupResponseNodePromise', () => {
|
describe('setupResponseNodePromise', () => {
|
||||||
const workflowId = 'test-workflow-id';
|
const workflowId = 'test-workflow-id';
|
||||||
const executionId = 'test-execution-id';
|
const executionId = 'test-execution-id';
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type { IWebhookResponseData } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { extractWebhookOnReceivedResponse } from '@/webhooks/webhook-on-received-response-extractor';
|
||||||
|
|
||||||
|
describe('extractWebhookOnReceivedResponse', () => {
|
||||||
|
const webhookResultData = mock<IWebhookResponseData>();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return response with no data when responseData is "noData"', () => {
|
||||||
|
const callbackData = extractWebhookOnReceivedResponse('noData', webhookResultData);
|
||||||
|
|
||||||
|
expect(callbackData).toEqual(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return response with responseData when it is defined', () => {
|
||||||
|
const responseData = JSON.stringify({ foo: 'bar' });
|
||||||
|
|
||||||
|
const callbackData = extractWebhookOnReceivedResponse(responseData, webhookResultData);
|
||||||
|
|
||||||
|
expect(callbackData).toEqual(responseData);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return response with webhookResponse when responseData is falsy but webhookResponse exists', () => {
|
||||||
|
const webhookResponse = { success: true };
|
||||||
|
webhookResultData.webhookResponse = webhookResponse;
|
||||||
|
|
||||||
|
const callbackData = extractWebhookOnReceivedResponse(undefined, webhookResultData);
|
||||||
|
|
||||||
|
expect(callbackData).toEqual(webhookResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return default response message when responseData and webhookResponse are falsy', () => {
|
||||||
|
webhookResultData.webhookResponse = undefined;
|
||||||
|
|
||||||
|
const callbackData = extractWebhookOnReceivedResponse(undefined, webhookResultData);
|
||||||
|
|
||||||
|
expect(callbackData).toEqual({ message: 'Workflow was started' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,6 +63,7 @@ import { WaitTracker } from '@/wait-tracker';
|
|||||||
import { WebhookExecutionContext } from '@/webhooks/webhook-execution-context';
|
import { WebhookExecutionContext } from '@/webhooks/webhook-execution-context';
|
||||||
import { createMultiFormDataParser } from '@/webhooks/webhook-form-data';
|
import { createMultiFormDataParser } from '@/webhooks/webhook-form-data';
|
||||||
import { extractWebhookLastNodeResponse } from '@/webhooks/webhook-last-node-response-extractor';
|
import { extractWebhookLastNodeResponse } from '@/webhooks/webhook-last-node-response-extractor';
|
||||||
|
import { extractWebhookOnReceivedResponse } from '@/webhooks/webhook-on-received-response-extractor';
|
||||||
import type { WebhookResponse } from '@/webhooks/webhook-response';
|
import type { WebhookResponse } from '@/webhooks/webhook-response';
|
||||||
import { createStaticResponse, createStreamResponse } from '@/webhooks/webhook-response';
|
import { createStaticResponse, createStreamResponse } from '@/webhooks/webhook-response';
|
||||||
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
||||||
@@ -205,28 +206,6 @@ export const handleFormRedirectionCase = (
|
|||||||
const { formDataFileSizeMax } = Container.get(GlobalConfig).endpoints;
|
const { formDataFileSizeMax } = Container.get(GlobalConfig).endpoints;
|
||||||
const parseFormData = createMultiFormDataParser(formDataFileSizeMax);
|
const parseFormData = createMultiFormDataParser(formDataFileSizeMax);
|
||||||
|
|
||||||
/** Return webhook response when responseMode is set to "onReceived" */
|
|
||||||
export function getResponseOnReceived(
|
|
||||||
responseData: WebhookResponseData | string | undefined,
|
|
||||||
webhookResultData: IWebhookResponseData,
|
|
||||||
responseCode: number,
|
|
||||||
): IWebhookResponseCallbackData {
|
|
||||||
const callbackData: IWebhookResponseCallbackData = { responseCode };
|
|
||||||
// Return response directly and do not wait for the workflow to finish
|
|
||||||
if (responseData === 'noData') {
|
|
||||||
// Return without data
|
|
||||||
} else if (responseData) {
|
|
||||||
// Return the data specified in the response data option
|
|
||||||
callbackData.data = responseData as unknown as IDataObject;
|
|
||||||
} else if (webhookResultData.webhookResponse !== undefined) {
|
|
||||||
// Data to respond with is given
|
|
||||||
callbackData.data = webhookResultData.webhookResponse;
|
|
||||||
} else {
|
|
||||||
callbackData.data = { message: 'Workflow was started' };
|
|
||||||
}
|
|
||||||
return callbackData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setupResponseNodePromise(
|
export function setupResponseNodePromise(
|
||||||
responsePromise: IDeferredPromise<IN8nHttpFullResponse>,
|
responsePromise: IDeferredPromise<IN8nHttpFullResponse>,
|
||||||
res: express.Response,
|
res: express.Response,
|
||||||
@@ -549,8 +528,9 @@ export async function executeWebhook(
|
|||||||
// Now that we know that the workflow should run we can return the default response
|
// Now that we know that the workflow should run we can return the default response
|
||||||
// directly if responseMode it set to "onReceived" and a response should be sent
|
// directly if responseMode it set to "onReceived" and a response should be sent
|
||||||
if (responseMode === 'onReceived' && !didSendResponse) {
|
if (responseMode === 'onReceived' && !didSendResponse) {
|
||||||
const callbackData = getResponseOnReceived(responseData, webhookResultData, responseCode);
|
const responseBody = extractWebhookOnReceivedResponse(responseData, webhookResultData);
|
||||||
responseCallback(null, callbackData);
|
const webhookResponse = createStaticResponse(responseBody, responseCode, responseHeaders);
|
||||||
|
responseCallback(null, webhookResponse);
|
||||||
didSendResponse = true;
|
didSendResponse = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { IWebhookResponseData, WebhookResponseData } from 'n8n-workflow';
|
||||||
|
|
||||||
|
/**
|
||||||
|
+ * Creates the response for a webhook when the response mode is set to
|
||||||
|
* `onReceived`.
|
||||||
|
*
|
||||||
|
* @param context - The webhook execution context
|
||||||
|
* @param responseData - The evaluated `responseData` option of the webhook node
|
||||||
|
* @param webhookResultData - The webhook result data that the webhook might have returned when it was ran
|
||||||
|
*
|
||||||
|
* @returns The response body
|
||||||
|
*/
|
||||||
|
export function extractWebhookOnReceivedResponse(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
|
||||||
|
responseData: Extract<WebhookResponseData, 'noData'> | string | undefined,
|
||||||
|
webhookResultData: IWebhookResponseData,
|
||||||
|
): unknown {
|
||||||
|
// Return response directly and do not wait for the workflow to finish
|
||||||
|
if (responseData === 'noData') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseData) {
|
||||||
|
return responseData;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (webhookResultData.webhookResponse !== undefined) {
|
||||||
|
// Data to respond with is given
|
||||||
|
return webhookResultData.webhookResponse as unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message: 'Workflow was started' };
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Logger } from '@n8n/backend-common';
|
import { Logger } from '@n8n/backend-common';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import type express from 'express';
|
import type express from 'express';
|
||||||
|
import {
|
||||||
|
isHtmlRenderedContentType,
|
||||||
|
sandboxHtmlResponse,
|
||||||
|
createHtmlSandboxTransformStream,
|
||||||
|
} from 'n8n-core';
|
||||||
import { ensureError, type IHttpRequestMethods } from 'n8n-workflow';
|
import { ensureError, type IHttpRequestMethods } from 'n8n-workflow';
|
||||||
import { finished } from 'stream/promises';
|
import { finished } from 'stream/promises';
|
||||||
|
|
||||||
@@ -121,8 +126,13 @@ class WebhookRequestHandler {
|
|||||||
this.setResponseStatus(res, code);
|
this.setResponseStatus(res, code);
|
||||||
this.setResponseHeaders(res, headers);
|
this.setResponseHeaders(res, headers);
|
||||||
|
|
||||||
stream.pipe(res, { end: false });
|
const contentType = res.getHeader('content-type') as string | undefined;
|
||||||
await finished(stream);
|
const needsSandbox = contentType && isHtmlRenderedContentType(contentType);
|
||||||
|
|
||||||
|
const streamToSend = needsSandbox ? stream.pipe(createHtmlSandboxTransformStream()) : stream;
|
||||||
|
streamToSend.pipe(res, { end: false });
|
||||||
|
await finished(streamToSend);
|
||||||
|
|
||||||
process.nextTick(() => res.end());
|
process.nextTick(() => res.end());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,12 +142,21 @@ class WebhookRequestHandler {
|
|||||||
this.setResponseStatus(res, code);
|
this.setResponseStatus(res, code);
|
||||||
this.setResponseHeaders(res, headers);
|
this.setResponseHeaders(res, headers);
|
||||||
|
|
||||||
|
const contentType = res.getHeader('content-type') as string | undefined;
|
||||||
|
|
||||||
if (typeof body === 'string') {
|
if (typeof body === 'string') {
|
||||||
res.send(body);
|
const needsSandbox = !contentType || isHtmlRenderedContentType(contentType);
|
||||||
|
const bodyToSend = needsSandbox ? sandboxHtmlResponse(body) : body;
|
||||||
|
res.send(bodyToSend);
|
||||||
|
} else {
|
||||||
|
const needsSandbox = contentType && isHtmlRenderedContentType(contentType);
|
||||||
|
if (needsSandbox) {
|
||||||
|
res.send(sandboxHtmlResponse(JSON.stringify(body)));
|
||||||
} else {
|
} else {
|
||||||
res.json(body);
|
res.json(body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private setResponseStatus(res: express.Response, statusCode?: number) {
|
private setResponseStatus(res: express.Response, statusCode?: number) {
|
||||||
if (statusCode !== undefined) {
|
if (statusCode !== undefined) {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
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 handle empty HTML 1`] = `
|
||||||
|
"<iframe srcdoc="" 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>"
|
||||||
|
`;
|
||||||
278
packages/core/src/__tests__/html-sandbox.test.ts
Normal file
278
packages/core/src/__tests__/html-sandbox.test.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
import {
|
||||||
|
bufferEscapeHtml,
|
||||||
|
createHtmlSandboxTransformStream,
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sandboxHtmlResponse', () => {
|
||||||
|
it('should replace ampersands and double quotes in HTML', () => {
|
||||||
|
const html = '<div class="test">Content & more</div>';
|
||||||
|
expect(sandboxHtmlResponse(html)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty HTML', () => {
|
||||||
|
const html = '';
|
||||||
|
expect(sandboxHtmlResponse(html)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle HTML with special characters', () => {
|
||||||
|
const html = '<p>Special characters: <>&"\'</p>';
|
||||||
|
expect(sandboxHtmlResponse(html)).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isHtmlRenderedContentType', () => {
|
||||||
|
it('should return true for text/html content type', () => {
|
||||||
|
const contentType = 'text/html';
|
||||||
|
expect(isHtmlRenderedContentType(contentType)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for application/xhtml+xml content type', () => {
|
||||||
|
const contentType = 'application/xhtml+xml';
|
||||||
|
expect(isHtmlRenderedContentType(contentType)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for other content types', () => {
|
||||||
|
const contentType = 'application/json';
|
||||||
|
expect(isHtmlRenderedContentType(contentType)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should handle various HTML content types', () => {
|
||||||
|
test.each([
|
||||||
|
'text/html',
|
||||||
|
'TEXT/HTML',
|
||||||
|
'text/html; charset=utf-8',
|
||||||
|
'text/html; charset=iso-8859-1',
|
||||||
|
'application/xhtml+xml',
|
||||||
|
'APPLICATION/XHTML+XML',
|
||||||
|
'application/xhtml+xml; charset=utf-8',
|
||||||
|
])('should return true for %s', (contentType) => {
|
||||||
|
expect(isHtmlRenderedContentType(contentType)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('should handle non-HTML content types', () => {
|
||||||
|
test.each([
|
||||||
|
'text/plain',
|
||||||
|
'application/xml',
|
||||||
|
'text/css',
|
||||||
|
'application/javascript',
|
||||||
|
'image/png',
|
||||||
|
'application/pdf',
|
||||||
|
'',
|
||||||
|
'html',
|
||||||
|
'xhtml',
|
||||||
|
])('should return false for %s', (contentType) => {
|
||||||
|
expect(isHtmlRenderedContentType(contentType)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle edge cases', () => {
|
||||||
|
expect(isHtmlRenderedContentType('text/htmlsomething')).toBe(true);
|
||||||
|
expect(isHtmlRenderedContentType('application/xhtml+xmlsomething')).toBe(true);
|
||||||
|
expect(isHtmlRenderedContentType(' text/html')).toBe(false);
|
||||||
|
expect(isHtmlRenderedContentType('text/html ')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
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()).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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
||||||
|
|
||||||
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 handle empty HTML 1`] = `
|
|
||||||
"
|
|
||||||
<iframe srcdoc="" 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>"
|
|
||||||
`;
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { isHtmlRenderedContentType, sandboxHtmlResponse } from '@/html-sandbox';
|
|
||||||
|
|
||||||
describe('sandboxHtmlResponse', () => {
|
|
||||||
it('should replace ampersands and double quotes in HTML', () => {
|
|
||||||
const html = '<div class="test">Content & more</div>';
|
|
||||||
expect(sandboxHtmlResponse(html)).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty HTML', () => {
|
|
||||||
const html = '';
|
|
||||||
expect(sandboxHtmlResponse(html)).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle HTML with special characters', () => {
|
|
||||||
const html = '<p>Special characters: <>&"\'</p>';
|
|
||||||
expect(sandboxHtmlResponse(html)).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isHtmlRenderedContentType', () => {
|
|
||||||
it('should return true for text/html content type', () => {
|
|
||||||
const contentType = 'text/html';
|
|
||||||
expect(isHtmlRenderedContentType(contentType)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for application/xhtml+xml content type', () => {
|
|
||||||
const contentType = 'application/xhtml+xml';
|
|
||||||
expect(isHtmlRenderedContentType(contentType)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for other content types', () => {
|
|
||||||
const contentType = 'application/json';
|
|
||||||
expect(isHtmlRenderedContentType(contentType)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('should handle various HTML content types', () => {
|
|
||||||
test.each([
|
|
||||||
'text/html',
|
|
||||||
'TEXT/HTML',
|
|
||||||
'text/html; charset=utf-8',
|
|
||||||
'text/html; charset=iso-8859-1',
|
|
||||||
'application/xhtml+xml',
|
|
||||||
'APPLICATION/XHTML+XML',
|
|
||||||
'application/xhtml+xml; charset=utf-8',
|
|
||||||
])('should return true for %s', (contentType) => {
|
|
||||||
expect(isHtmlRenderedContentType(contentType)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('should handle non-HTML content types', () => {
|
|
||||||
test.each([
|
|
||||||
'text/plain',
|
|
||||||
'application/xml',
|
|
||||||
'text/css',
|
|
||||||
'application/javascript',
|
|
||||||
'image/png',
|
|
||||||
'application/pdf',
|
|
||||||
'',
|
|
||||||
'html',
|
|
||||||
'xhtml',
|
|
||||||
])('should return false for %s', (contentType) => {
|
|
||||||
expect(isHtmlRenderedContentType(contentType)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle edge cases', () => {
|
|
||||||
expect(isHtmlRenderedContentType('text/htmlsomething')).toBe(true);
|
|
||||||
expect(isHtmlRenderedContentType('application/xhtml+xmlsomething')).toBe(true);
|
|
||||||
expect(isHtmlRenderedContentType(' text/html')).toBe(false);
|
|
||||||
expect(isHtmlRenderedContentType('text/html ')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { TransformCallback } from 'stream';
|
||||||
|
import { Transform } from 'stream';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sandboxes the HTML response to prevent possible exploitation. Embeds the
|
* Sandboxes the HTML response to prevent possible exploitation. Embeds the
|
||||||
* response in an iframe to make sure the HTML has a different origin.
|
* response in an iframe to make sure the HTML has a different origin.
|
||||||
@@ -7,11 +10,98 @@ export const sandboxHtmlResponse = (html: string) => {
|
|||||||
// https://html.spec.whatwg.org/multipage/iframe-embed-object.html#the-iframe-element
|
// https://html.spec.whatwg.org/multipage/iframe-embed-object.html#the-iframe-element
|
||||||
const escapedHtml = html.replaceAll('&', '&').replaceAll('"', '"');
|
const escapedHtml = html.replaceAll('&', '&').replaceAll('"', '"');
|
||||||
|
|
||||||
return `
|
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"
|
||||||
<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;"
|
style="position:fixed; top:0; left:0; width:100vw; height:100vh; border:none; overflow:auto;"
|
||||||
allowtransparency="true">
|
allowtransparency="true"></iframe>`;
|
||||||
</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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user