mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +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.
|
||||
|
||||
## 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
|
||||
|
||||
### What changed?
|
||||
@@ -12,8 +22,8 @@ with libraries like `puppeteer` at the cost of security.
|
||||
|
||||
### When is action necessary?
|
||||
|
||||
If you are using the `N8N_RUNNERS_ALLOW_PROTOTYPE_MUTATION` flag, or if you find that the task runner does not
|
||||
currently support an external module that you rely on, then consider setting `N8N_RUNNERS_INSECURE_MODE=true`,
|
||||
If you are using the `N8N_RUNNERS_ALLOW_PROTOTYPE_MUTATION` flag, or if you find that the task runner does not
|
||||
currently support an external module that you rely on, then consider setting `N8N_RUNNERS_INSECURE_MODE=true`,
|
||||
at your own risk.
|
||||
|
||||
## 1.98.0
|
||||
|
||||
@@ -21,7 +21,6 @@ import { finished } from 'stream/promises';
|
||||
import {
|
||||
autoDetectResponseMode,
|
||||
handleFormRedirectionCase,
|
||||
getResponseOnReceived,
|
||||
setupResponseNodePromise,
|
||||
prepareExecutionData,
|
||||
} 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', () => {
|
||||
const workflowId = 'test-workflow-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 { createMultiFormDataParser } from '@/webhooks/webhook-form-data';
|
||||
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 { createStaticResponse, createStreamResponse } from '@/webhooks/webhook-response';
|
||||
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
||||
@@ -205,28 +206,6 @@ export const handleFormRedirectionCase = (
|
||||
const { formDataFileSizeMax } = Container.get(GlobalConfig).endpoints;
|
||||
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(
|
||||
responsePromise: IDeferredPromise<IN8nHttpFullResponse>,
|
||||
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
|
||||
// directly if responseMode it set to "onReceived" and a response should be sent
|
||||
if (responseMode === 'onReceived' && !didSendResponse) {
|
||||
const callbackData = getResponseOnReceived(responseData, webhookResultData, responseCode);
|
||||
responseCallback(null, callbackData);
|
||||
const responseBody = extractWebhookOnReceivedResponse(responseData, webhookResultData);
|
||||
const webhookResponse = createStaticResponse(responseBody, responseCode, responseHeaders);
|
||||
responseCallback(null, webhookResponse);
|
||||
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 { Container } from '@n8n/di';
|
||||
import type express from 'express';
|
||||
import {
|
||||
isHtmlRenderedContentType,
|
||||
sandboxHtmlResponse,
|
||||
createHtmlSandboxTransformStream,
|
||||
} from 'n8n-core';
|
||||
import { ensureError, type IHttpRequestMethods } from 'n8n-workflow';
|
||||
import { finished } from 'stream/promises';
|
||||
|
||||
@@ -121,8 +126,13 @@ class WebhookRequestHandler {
|
||||
this.setResponseStatus(res, code);
|
||||
this.setResponseHeaders(res, headers);
|
||||
|
||||
stream.pipe(res, { end: false });
|
||||
await finished(stream);
|
||||
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);
|
||||
|
||||
process.nextTick(() => res.end());
|
||||
}
|
||||
|
||||
@@ -132,10 +142,19 @@ class WebhookRequestHandler {
|
||||
this.setResponseStatus(res, code);
|
||||
this.setResponseHeaders(res, headers);
|
||||
|
||||
const contentType = res.getHeader('content-type') as string | undefined;
|
||||
|
||||
if (typeof body === 'string') {
|
||||
res.send(body);
|
||||
const needsSandbox = !contentType || isHtmlRenderedContentType(contentType);
|
||||
const bodyToSend = needsSandbox ? sandboxHtmlResponse(body) : body;
|
||||
res.send(bodyToSend);
|
||||
} else {
|
||||
res.json(body);
|
||||
const needsSandbox = contentType && isHtmlRenderedContentType(contentType);
|
||||
if (needsSandbox) {
|
||||
res.send(sandboxHtmlResponse(JSON.stringify(body)));
|
||||
} else {
|
||||
res.json(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
* 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
|
||||
const escapedHtml = html.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"
|
||||
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>`;
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user