fix(Webhook Node): Don't wrap response in an iframe if it doesn't have HTML (#17671)

This commit is contained in:
RomanDavydchuk
2025-07-25 17:10:16 +03:00
committed by GitHub
parent 9c793a45c5
commit 69beafbf71
5 changed files with 86 additions and 13 deletions

View File

@@ -56,6 +56,7 @@
"http-proxy-agent": "catalog:",
"https-proxy-agent": "catalog:",
"iconv-lite": "catalog:",
"jsdom": "23.0.1",
"jsonwebtoken": "catalog:",
"lodash": "catalog:",
"luxon": "catalog:",

View File

@@ -1,13 +1,13 @@
// 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"
exports[`sandboxHtmlResponse should always sandbox if forceSandbox is true 1`] = `
"<iframe srcdoc="Hello World" 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"
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>"
`;
@@ -17,3 +17,21 @@ exports[`sandboxHtmlResponse should replace ampersands and double quotes in HTML
style="position:fixed; top:0; left:0; width:100vw; height:100vh; border:none; overflow:auto;"
allowtransparency="true"></iframe>"
`;
exports[`sandboxHtmlResponse should sandbox even with no <body> tag 1`] = `
"<iframe srcdoc="<html><head><title>Test</title><script>alert(&quot;Hello&quot;)</script></head></html>" 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 sandbox when outside <body> and <head> tags 1`] = `
"<iframe srcdoc="<html><head><title>Test</title></head><body></body><script>alert(&quot;Hello&quot;)</script></html>" 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 sandbox when outside <html> tag 1`] = `
"<iframe srcdoc="<html><head><title>Test</title></head></html><script>alert(&quot;Hello&quot;)</script>" 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

@@ -24,15 +24,42 @@ describe('sandboxHtmlResponse', () => {
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();
});
it.each([
['Hello World', 'Hello World'],
['< not html >', '< not html >'],
['# Test', '# Test'],
['', ''],
[123, '123'],
[null, 'null'],
])('should not sandbox if not html', (data, expected) => {
expect(sandboxHtmlResponse(data)).toBe(expected);
});
it('should sandbox even with no <body> tag', () => {
const html = '<html><head><title>Test</title><script>alert("Hello")</script></head></html>';
expect(sandboxHtmlResponse(html)).toMatchSnapshot();
});
it('should sandbox when outside <body> and <head> tags', () => {
const html =
'<html><head><title>Test</title></head><body></body><script>alert("Hello")</script></html>';
expect(sandboxHtmlResponse(html)).toMatchSnapshot();
});
it('should sandbox when outside <html> tag', () => {
const html = '<html><head><title>Test</title></head></html><script>alert("Hello")</script>';
expect(sandboxHtmlResponse(html)).toMatchSnapshot();
});
it('should always sandbox if forceSandbox is true', () => {
const text = 'Hello World';
expect(sandboxHtmlResponse(text, true)).toMatchSnapshot();
});
});
describe('isHtmlRenderedContentType', () => {
@@ -143,7 +170,7 @@ describe('bufferEscapeHtml', () => {
describe('createHtmlSandboxTransformStream', () => {
const getComparableHtml = (input: Buffer | string) =>
sandboxHtmlResponse(input.toString()).replace(/\s+/g, ' ');
sandboxHtmlResponse(input.toString(), true).replace(/\s+/g, ' ');
it('should wrap single chunk in iframe with proper escaping', async () => {
const input = Buffer.from('Hello & "World"', 'utf8');

View File

@@ -1,11 +1,31 @@
import { JSDOM } from 'jsdom';
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.
* Checks if the given string contains HTML.
*/
export const sandboxHtmlResponse = <T>(data: T) => {
export const hasHtml = (str: string) => {
try {
const dom = new JSDOM(str);
return (
dom.window.document.body.children.length > 0 || dom.window.document.head.children.length > 0
);
} catch {
return false;
}
};
/**
* Sandboxes the HTML response to prevent possible exploitation, if the data has HTML.
* If the data does not have HTML, it will be returned as is.
* Otherwise, it embeds the response in an iframe to make sure the HTML has a different origin.
*
* @param data - The data to sandbox.
* @param forceSandbox - Whether to force sandboxing even if the data does not contain HTML.
* @returns The sandboxed HTML response.
*/
export const sandboxHtmlResponse = <T>(data: T, forceSandbox = false) => {
let text;
if (typeof data !== 'string') {
text = JSON.stringify(data);
@@ -13,6 +33,10 @@ export const sandboxHtmlResponse = <T>(data: T) => {
text = data;
}
if (!forceSandbox && !hasHtml(text)) {
return text;
}
// Escape & and " as mentioned in the spec:
// https://html.spec.whatwg.org/multipage/iframe-embed-object.html#the-iframe-element
const escapedHtml = text.replaceAll('&', '&amp;').replaceAll('"', '&quot;');

3
pnpm-lock.yaml generated
View File

@@ -1739,6 +1739,9 @@ importers:
iconv-lite:
specifier: 'catalog:'
version: 0.6.3
jsdom:
specifier: 23.0.1
version: 23.0.1
jsonwebtoken:
specifier: 'catalog:'
version: 9.0.2