diff --git a/packages/cli/BREAKING-CHANGES.md b/packages/cli/BREAKING-CHANGES.md index 5af3513422..dc0fc37dab 100644 --- a/packages/cli/BREAKING-CHANGES.md +++ b/packages/cli/BREAKING-CHANGES.md @@ -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 diff --git a/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts b/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts index 72f0305ee5..268ee29cb6 100644 --- a/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts +++ b/packages/cli/src/webhooks/__tests__/webhook-helpers.test.ts @@ -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(); - - 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'; diff --git a/packages/cli/src/webhooks/__tests__/webhook-on-received-response-extractor.test.ts b/packages/cli/src/webhooks/__tests__/webhook-on-received-response-extractor.test.ts new file mode 100644 index 0000000000..d82d502000 --- /dev/null +++ b/packages/cli/src/webhooks/__tests__/webhook-on-received-response-extractor.test.ts @@ -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(); + + 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' }); + }); +}); diff --git a/packages/cli/src/webhooks/webhook-helpers.ts b/packages/cli/src/webhooks/webhook-helpers.ts index 2d1bf80fed..4392115b38 100644 --- a/packages/cli/src/webhooks/webhook-helpers.ts +++ b/packages/cli/src/webhooks/webhook-helpers.ts @@ -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, 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; } diff --git a/packages/cli/src/webhooks/webhook-on-received-response-extractor.ts b/packages/cli/src/webhooks/webhook-on-received-response-extractor.ts new file mode 100644 index 0000000000..ce39122cd4 --- /dev/null +++ b/packages/cli/src/webhooks/webhook-on-received-response-extractor.ts @@ -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 | 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' }; +} diff --git a/packages/cli/src/webhooks/webhook-request-handler.ts b/packages/cli/src/webhooks/webhook-request-handler.ts index 183b2a98d1..dd2eb671ad 100644 --- a/packages/cli/src/webhooks/webhook-request-handler.ts +++ b/packages/cli/src/webhooks/webhook-request-handler.ts @@ -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); + } } } diff --git a/packages/core/src/__tests__/__snapshots__/html-sandbox.test.ts.snap b/packages/core/src/__tests__/__snapshots__/html-sandbox.test.ts.snap new file mode 100644 index 0000000000..e1eed34e3a --- /dev/null +++ b/packages/core/src/__tests__/__snapshots__/html-sandbox.test.ts.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`sandboxHtmlResponse should handle HTML with special characters 1`] = ` +"" +`; + +exports[`sandboxHtmlResponse should handle empty HTML 1`] = ` +"" +`; + +exports[`sandboxHtmlResponse should replace ampersands and double quotes in HTML 1`] = ` +"" +`; diff --git a/packages/core/src/__tests__/html-sandbox.test.ts b/packages/core/src/__tests__/html-sandbox.test.ts new file mode 100644 index 0000000000..a85013e080 --- /dev/null +++ b/packages/core/src/__tests__/html-sandbox.test.ts @@ -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 { + 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 = '
Content & more
'; + expect(sandboxHtmlResponse(html)).toMatchSnapshot(); + }); + + it('should handle empty HTML', () => { + const html = ''; + expect(sandboxHtmlResponse(html)).toMatchSnapshot(); + }); + + it('should handle HTML with special characters', () => { + const html = '

Special characters: <>&"\'

'; + 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 & "Test"', 'utf8'); + const result = bufferEscapeHtml(input); + + expect(result.toString()).toBe('Hello & "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('
&"Hello"
', '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('

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)); + }); +}); diff --git a/packages/core/src/execution-engine/node-execution-context/__tests__/__snapshots__/html-sandbox.test.ts.snap b/packages/core/src/execution-engine/node-execution-context/__tests__/__snapshots__/html-sandbox.test.ts.snap deleted file mode 100644 index ae20790253..0000000000 --- a/packages/core/src/execution-engine/node-execution-context/__tests__/__snapshots__/html-sandbox.test.ts.snap +++ /dev/null @@ -1,25 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`sandboxHtmlResponse should handle HTML with special characters 1`] = ` -" - " -`; - -exports[`sandboxHtmlResponse should handle empty HTML 1`] = ` -" - " -`; - -exports[`sandboxHtmlResponse should replace ampersands and double quotes in HTML 1`] = ` -" - " -`; diff --git a/packages/core/src/execution-engine/node-execution-context/__tests__/html-sandbox.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/html-sandbox.test.ts deleted file mode 100644 index b33a7b2196..0000000000 --- a/packages/core/src/execution-engine/node-execution-context/__tests__/html-sandbox.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { isHtmlRenderedContentType, sandboxHtmlResponse } from '@/html-sandbox'; - -describe('sandboxHtmlResponse', () => { - it('should replace ampersands and double quotes in HTML', () => { - const html = '
Content & more
'; - expect(sandboxHtmlResponse(html)).toMatchSnapshot(); - }); - - it('should handle empty HTML', () => { - const html = ''; - expect(sandboxHtmlResponse(html)).toMatchSnapshot(); - }); - - it('should handle HTML with special characters', () => { - const html = '

Special characters: <>&"\'

'; - 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); - }); -}); diff --git a/packages/core/src/html-sandbox.ts b/packages/core/src/html-sandbox.ts index 5581cb307e..2ecae9f8aa 100644 --- a/packages/core/src/html-sandbox.ts +++ b/packages/core/src/html-sandbox.ts @@ -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 ` - `; + allowtransparency="true">`; +}; + +/** + * 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('', + '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); + } + }, + }); }; /**