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:
Tomi Turtiainen
2025-07-16 12:53:36 +03:00
committed by GitHub
parent 5db8bbd126
commit cca1c2d810
11 changed files with 506 additions and 175 deletions

View File

@@ -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

View File

@@ -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';

View File

@@ -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' });
});
});

View File

@@ -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;
}

View File

@@ -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' };
}

View File

@@ -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);
}
}
}

View File

@@ -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: <>&amp;&quot;'</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=&quot;test&quot;>Content &amp; 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>"
`;

View 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([
['&', '&amp;'],
['"', '&quot;'],
['&"', '&amp;&quot;'],
['Hello & World', 'Hello &amp; World'],
['Hello "World"', 'Hello &quot;World&quot;'],
['Hello & "World"', 'Hello &amp; &quot;World&quot;'],
['Hello && World', 'Hello &amp;&amp; World'],
['Hello ""World""', 'Hello &quot;&quot;World&quot;&quot;'],
['&"Hello"&"World"&', '&amp;&quot;Hello&quot;&amp;&quot;World&quot;&amp;'],
])('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 &amp; 世界 &quot;World&quot; &amp; こんにちは');
});
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> &amp; &quot;Test&quot;');
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));
});
});

View File

@@ -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: <>&amp;&quot;'</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=&quot;test&quot;>Content &amp; 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>"
`;

View File

@@ -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);
});
});

View File

@@ -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('&', '&amp;').replaceAll('"', '&quot;');
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 &amp; &quot;World&quot;'
* ```
*/
export const bufferEscapeHtml = (input: Buffer) => {
const ampersand = Buffer.from('&', 'utf8').readUInt8(0);
const escapedAmpersand = Buffer.from('&amp;', 'utf8');
const doublequote = Buffer.from('"', 'utf8').readUInt8(0);
const escapedDoublequote = Buffer.from('&quot;', '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);
}
},
});
};
/**