first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,454 @@
|
||||
import basicAuth from 'basic-auth';
|
||||
import { rm } from 'fs/promises';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { WorkflowConfigurationError } from 'n8n-workflow';
|
||||
import type {
|
||||
IWebhookFunctions,
|
||||
INodeExecutionData,
|
||||
IDataObject,
|
||||
ICredentialDataDecryptedObject,
|
||||
MultiPartFormData,
|
||||
INode,
|
||||
} from 'n8n-workflow';
|
||||
import * as a from 'node:assert';
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { BlockList, isIPv6 } from 'node:net';
|
||||
|
||||
import { WebhookAuthorizationError } from './error';
|
||||
import { formatPrivateKey } from '../../utils/utilities';
|
||||
|
||||
export type WebhookParameters = {
|
||||
httpMethod: string | string[];
|
||||
responseMode: string;
|
||||
responseData: string;
|
||||
responseCode?: number; //typeVersion <= 1.1
|
||||
options?: {
|
||||
responseData?: string;
|
||||
responseCode?: {
|
||||
values?: {
|
||||
responseCode: number;
|
||||
customCode?: number;
|
||||
};
|
||||
};
|
||||
noResponseBody?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const getResponseCode = (parameters: WebhookParameters) => {
|
||||
if (parameters.responseCode) {
|
||||
return parameters.responseCode;
|
||||
}
|
||||
const responseCodeOptions = parameters.options;
|
||||
if (responseCodeOptions?.responseCode?.values) {
|
||||
const { responseCode, customCode } = responseCodeOptions.responseCode.values;
|
||||
|
||||
if (customCode) {
|
||||
return customCode;
|
||||
}
|
||||
|
||||
return responseCode;
|
||||
}
|
||||
return 200;
|
||||
};
|
||||
|
||||
export const getResponseData = (parameters: WebhookParameters) => {
|
||||
const { responseData, responseMode, options } = parameters;
|
||||
if (responseData) return responseData;
|
||||
|
||||
if (responseMode === 'onReceived') {
|
||||
const data = options?.responseData;
|
||||
if (data) return data;
|
||||
}
|
||||
|
||||
if (options?.noResponseBody) return 'noData';
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const configuredOutputs = (parameters: WebhookParameters) => {
|
||||
const httpMethod = parameters.httpMethod;
|
||||
|
||||
if (!Array.isArray(httpMethod))
|
||||
return [
|
||||
{
|
||||
type: 'main',
|
||||
displayName: httpMethod,
|
||||
},
|
||||
];
|
||||
|
||||
const outputs = httpMethod.map((method) => {
|
||||
return {
|
||||
type: 'main',
|
||||
displayName: method,
|
||||
};
|
||||
});
|
||||
|
||||
return outputs;
|
||||
};
|
||||
|
||||
export const setupOutputConnection = (
|
||||
ctx: IWebhookFunctions,
|
||||
method: string,
|
||||
additionalData: {
|
||||
jwtPayload?: IDataObject;
|
||||
},
|
||||
) => {
|
||||
const httpMethod = ctx.getNodeParameter('httpMethod', []) as string[] | string;
|
||||
let webhookUrl = ctx.getNodeWebhookUrl('default') as string;
|
||||
const executionMode = ctx.getMode() === 'manual' ? 'test' : 'production';
|
||||
|
||||
if (executionMode === 'test') {
|
||||
webhookUrl = webhookUrl.replace('/webhook/', '/webhook-test/');
|
||||
}
|
||||
|
||||
// multi methods could be set in settings of node, so we need to check if it's an array
|
||||
if (!Array.isArray(httpMethod)) {
|
||||
return (outputData: INodeExecutionData): INodeExecutionData[][] => {
|
||||
outputData.json.webhookUrl = webhookUrl;
|
||||
outputData.json.executionMode = executionMode;
|
||||
if (additionalData?.jwtPayload) {
|
||||
outputData.json.jwtPayload = additionalData.jwtPayload;
|
||||
}
|
||||
return [[outputData]];
|
||||
};
|
||||
}
|
||||
|
||||
const outputIndex = httpMethod.indexOf(method.toUpperCase());
|
||||
const outputs: INodeExecutionData[][] = httpMethod.map(() => []);
|
||||
|
||||
return (outputData: INodeExecutionData): INodeExecutionData[][] => {
|
||||
outputData.json.webhookUrl = webhookUrl;
|
||||
outputData.json.executionMode = executionMode;
|
||||
if (additionalData?.jwtPayload) {
|
||||
outputData.json.jwtPayload = additionalData.jwtPayload;
|
||||
}
|
||||
outputs[outputIndex] = [outputData];
|
||||
return outputs;
|
||||
};
|
||||
};
|
||||
|
||||
export const isIpAllowed = (
|
||||
allowlist: string | string[] | undefined,
|
||||
ips: string[],
|
||||
ip?: string,
|
||||
) => {
|
||||
if (allowlist === undefined || allowlist === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!Array.isArray(allowlist)) {
|
||||
allowlist = allowlist.split(',').map((entry) => entry.trim());
|
||||
}
|
||||
|
||||
const allowList = getAllowList(allowlist);
|
||||
|
||||
// Check the primary IP address with proper family detection
|
||||
if (ip) {
|
||||
const ipFamily = isIPv6(ip) ? 'ipv6' : 'ipv4';
|
||||
if (allowList.check(ip, ipFamily)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check proxy IPs with proper family detection
|
||||
if (
|
||||
ips.some((ipEntry) => {
|
||||
const ipFamily = isIPv6(ipEntry) ? 'ipv6' : 'ipv4';
|
||||
return allowList.check(ipEntry, ipFamily);
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getAllowList = (allowlist: string[]) => {
|
||||
const allowList = new BlockList();
|
||||
|
||||
for (const entry of allowlist) {
|
||||
try {
|
||||
// Check if entry is in CIDR notation (contains /)
|
||||
if (entry.includes('/')) {
|
||||
const [network, prefixStr] = entry.split('/');
|
||||
const prefix = parseInt(prefixStr, 10);
|
||||
|
||||
// Validate prefix is a number
|
||||
if (isNaN(prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect IP type (IPv4 vs IPv6)
|
||||
const type = network.includes(':') ? 'ipv6' : 'ipv4';
|
||||
|
||||
// Validate prefix range
|
||||
const maxPrefix = type === 'ipv4' ? 32 : 128;
|
||||
if (prefix < 0 || prefix > maxPrefix) {
|
||||
continue;
|
||||
}
|
||||
|
||||
allowList.addSubnet(network, prefix, type);
|
||||
} else {
|
||||
// Single IP address
|
||||
const type = entry.includes(':') ? 'ipv6' : 'ipv4';
|
||||
allowList.addAddress(entry, type);
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid entries
|
||||
}
|
||||
}
|
||||
|
||||
return allowList;
|
||||
};
|
||||
|
||||
export const checkResponseModeConfiguration = (context: IWebhookFunctions) => {
|
||||
const responseMode = context.getNodeParameter('responseMode', 'onReceived') as string;
|
||||
const connectedNodes = context.getChildNodes(context.getNode().name);
|
||||
|
||||
const isRespondToWebhookConnected = connectedNodes.some(
|
||||
(node) => node.type === 'n8n-nodes-base.respondToWebhook',
|
||||
);
|
||||
|
||||
if (!isRespondToWebhookConnected && responseMode === 'responseNode') {
|
||||
throw new WorkflowConfigurationError(
|
||||
context.getNode(),
|
||||
new Error('No Respond to Webhook node found in the workflow'),
|
||||
{
|
||||
description:
|
||||
'Insert a Respond to Webhook node to your workflow to respond to the webhook or choose another option for the “Respond” parameter',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (isRespondToWebhookConnected && !['responseNode', 'streaming'].includes(responseMode)) {
|
||||
throw new WorkflowConfigurationError(
|
||||
context.getNode(),
|
||||
new Error('Unused Respond to Webhook node found in the workflow'),
|
||||
{
|
||||
description:
|
||||
'Set the “Respond” parameter to “Using Respond to Webhook Node” or remove the Respond to Webhook node',
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export async function validateWebhookAuthentication(
|
||||
ctx: IWebhookFunctions,
|
||||
authPropertyName: string,
|
||||
) {
|
||||
const authentication = ctx.getNodeParameter(authPropertyName) as string;
|
||||
if (authentication === 'none') return;
|
||||
|
||||
const req = ctx.getRequestObject();
|
||||
const headers = ctx.getHeaderData();
|
||||
|
||||
if (authentication === 'basicAuth') {
|
||||
// Basic authorization is needed to call webhook
|
||||
let expectedAuth: ICredentialDataDecryptedObject | undefined;
|
||||
try {
|
||||
expectedAuth = await ctx.getCredentials<ICredentialDataDecryptedObject>('httpBasicAuth');
|
||||
} catch {}
|
||||
|
||||
if (expectedAuth === undefined || !expectedAuth.user || !expectedAuth.password) {
|
||||
// Data is not defined on node so can not authenticate
|
||||
throw new WebhookAuthorizationError(500, 'No authentication data defined on node!');
|
||||
}
|
||||
|
||||
const providedAuth = basicAuth(req);
|
||||
// Authorization data is missing
|
||||
if (!providedAuth) {
|
||||
const authToken = headers['x-auth-token'];
|
||||
if (!authToken) {
|
||||
throw new WebhookAuthorizationError(401);
|
||||
}
|
||||
|
||||
const expectedAuthToken = generateBasicAuthToken(ctx.getNode(), expectedAuth);
|
||||
if (
|
||||
!expectedAuthToken ||
|
||||
typeof authToken !== 'string' ||
|
||||
expectedAuthToken.length !== authToken.length ||
|
||||
!timingSafeEqual(Buffer.from(expectedAuthToken), Buffer.from(authToken))
|
||||
) {
|
||||
throw new WebhookAuthorizationError(403);
|
||||
}
|
||||
} else if (
|
||||
providedAuth.name !== expectedAuth.user ||
|
||||
providedAuth.pass !== expectedAuth.password
|
||||
) {
|
||||
// Provided authentication data is wrong
|
||||
throw new WebhookAuthorizationError(403);
|
||||
}
|
||||
} else if (authentication === 'bearerAuth') {
|
||||
let expectedAuth: ICredentialDataDecryptedObject | undefined;
|
||||
try {
|
||||
expectedAuth = await ctx.getCredentials<ICredentialDataDecryptedObject>('httpBearerAuth');
|
||||
} catch {}
|
||||
|
||||
const expectedToken = expectedAuth?.token as string;
|
||||
if (!expectedToken) {
|
||||
throw new WebhookAuthorizationError(500, 'No authentication data defined on node!');
|
||||
}
|
||||
|
||||
if (headers.authorization !== `Bearer ${expectedToken}`) {
|
||||
throw new WebhookAuthorizationError(403);
|
||||
}
|
||||
} else if (authentication === 'headerAuth') {
|
||||
// Special header with value is needed to call webhook
|
||||
let expectedAuth: ICredentialDataDecryptedObject | undefined;
|
||||
try {
|
||||
expectedAuth = await ctx.getCredentials<ICredentialDataDecryptedObject>('httpHeaderAuth');
|
||||
} catch {}
|
||||
|
||||
if (expectedAuth === undefined || !expectedAuth.name || !expectedAuth.value) {
|
||||
// Data is not defined on node so can not authenticate
|
||||
throw new WebhookAuthorizationError(500, 'No authentication data defined on node!');
|
||||
}
|
||||
const headerName = (expectedAuth.name as string).toLowerCase();
|
||||
const expectedValue = expectedAuth.value as string;
|
||||
|
||||
if (
|
||||
!headers.hasOwnProperty(headerName) ||
|
||||
(headers as IDataObject)[headerName] !== expectedValue
|
||||
) {
|
||||
// Provided authentication data is wrong
|
||||
throw new WebhookAuthorizationError(403);
|
||||
}
|
||||
} else if (authentication === 'jwtAuth') {
|
||||
let expectedAuth;
|
||||
|
||||
try {
|
||||
expectedAuth = await ctx.getCredentials<{
|
||||
keyType: 'passphrase' | 'pemKey';
|
||||
publicKey: string;
|
||||
secret: string;
|
||||
algorithm: jwt.Algorithm;
|
||||
}>('jwtAuth');
|
||||
} catch {}
|
||||
|
||||
if (expectedAuth === undefined) {
|
||||
// Data is not defined on node so can not authenticate
|
||||
throw new WebhookAuthorizationError(500, 'No authentication data defined on node!');
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader?.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
throw new WebhookAuthorizationError(401, 'No token provided');
|
||||
}
|
||||
|
||||
let secretOrPublicKey;
|
||||
|
||||
if (expectedAuth.keyType === 'passphrase') {
|
||||
secretOrPublicKey = expectedAuth.secret;
|
||||
} else {
|
||||
secretOrPublicKey = formatPrivateKey(expectedAuth.publicKey, true);
|
||||
}
|
||||
|
||||
try {
|
||||
return jwt.verify(token, secretOrPublicKey, {
|
||||
algorithms: [expectedAuth.algorithm],
|
||||
}) as IDataObject;
|
||||
} catch (error) {
|
||||
throw new WebhookAuthorizationError(403, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleFormData(
|
||||
context: IWebhookFunctions,
|
||||
prepareOutput: (data: INodeExecutionData) => INodeExecutionData[][],
|
||||
) {
|
||||
const req = context.getRequestObject() as MultiPartFormData.Request;
|
||||
a.ok(req.contentType === 'multipart/form-data', 'Expected multipart/form-data');
|
||||
const options = context.getNodeParameter('options', {}) as IDataObject;
|
||||
const { data, files } = req.body;
|
||||
|
||||
const returnItem: INodeExecutionData = {
|
||||
json: {
|
||||
headers: req.headers,
|
||||
params: req.params,
|
||||
query: req.query,
|
||||
body: data,
|
||||
},
|
||||
};
|
||||
|
||||
if (files && Object.keys(files).length) {
|
||||
returnItem.binary = {};
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
|
||||
for (const key of Object.keys(files)) {
|
||||
const processFiles: MultiPartFormData.File[] = [];
|
||||
let multiFile = false;
|
||||
if (Array.isArray(files[key])) {
|
||||
processFiles.push.apply(processFiles, files[key]);
|
||||
multiFile = true;
|
||||
} else {
|
||||
processFiles.push(files[key]);
|
||||
}
|
||||
|
||||
let fileCount = 0;
|
||||
for (const file of processFiles) {
|
||||
let binaryPropertyName = key;
|
||||
if (binaryPropertyName.endsWith('[]')) {
|
||||
binaryPropertyName = binaryPropertyName.slice(0, -2);
|
||||
}
|
||||
if (!binaryPropertyName.trim().length) {
|
||||
binaryPropertyName = `data${count}`;
|
||||
} else if (multiFile) {
|
||||
binaryPropertyName += fileCount++;
|
||||
}
|
||||
if (options.binaryPropertyName) {
|
||||
binaryPropertyName = `${options.binaryPropertyName}${count}`;
|
||||
}
|
||||
|
||||
returnItem.binary![binaryPropertyName] = await context.nodeHelpers.copyBinaryFile(
|
||||
file.filepath,
|
||||
file.originalFilename ?? file.newFilename,
|
||||
file.mimetype,
|
||||
);
|
||||
|
||||
// Delete original file to prevent tmp directory from growing too large
|
||||
await rm(file.filepath, { force: true });
|
||||
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { workflowData: prepareOutput(returnItem) };
|
||||
}
|
||||
|
||||
export async function generateFormPostBasicAuthToken(
|
||||
context: IWebhookFunctions,
|
||||
authPropertyName: string,
|
||||
) {
|
||||
const node = context.getNode();
|
||||
|
||||
const authentication = context.getNodeParameter(authPropertyName);
|
||||
if (authentication === 'none') return;
|
||||
|
||||
let credentials: ICredentialDataDecryptedObject | undefined;
|
||||
|
||||
try {
|
||||
credentials = await context.getCredentials<ICredentialDataDecryptedObject>('httpBasicAuth');
|
||||
} catch {}
|
||||
|
||||
return generateBasicAuthToken(node, credentials);
|
||||
}
|
||||
|
||||
export function generateBasicAuthToken(
|
||||
node: INode,
|
||||
credentials: ICredentialDataDecryptedObject | undefined,
|
||||
) {
|
||||
if (!credentials || !credentials.user || !credentials.password) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = createHmac('sha256', `${credentials.user}:${credentials.password}`)
|
||||
.update(`${node.id}-${node.webhookId}`)
|
||||
.digest('hex');
|
||||
|
||||
return token;
|
||||
}
|
||||
Reference in New Issue
Block a user