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