feat: improve webhook integration (customized body and headers)

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-23 14:55:10 +01:00
parent f8f470adf9
commit 286f8e160b
20 changed files with 1994 additions and 91 deletions

View File

@@ -0,0 +1,280 @@
import { parse } from '@babel/parser';
import {
ALLOWED_GLOBALS,
ALLOWED_INSTANCE_METHODS,
ALLOWED_METHODS,
} from './constants';
import {
collectDeclaredIdentifiers,
isPropertyKey,
walkNode,
} from './ast-walker';
/**
* Validates that a JavaScript function is safe to execute
* by checking the AST for allowed operations only (allowlist approach)
*/
export function validate(code: string): {
valid: boolean;
error?: string;
} {
if (!code || typeof code !== 'string') {
return { valid: false, error: 'Code must be a non-empty string' };
}
try {
// Parse the code to AST
const ast = parse(code, {
sourceType: 'module',
allowReturnOutsideFunction: true,
plugins: ['typescript'],
});
// Validate root structure: must be exactly one function expression
const program = ast.program;
const body = program.body;
if (body.length === 0) {
return { valid: false, error: 'Code cannot be empty' };
}
if (body.length > 1) {
return {
valid: false,
error:
'Code must contain only a single function. Multiple statements are not allowed.',
};
}
const rootStatement = body[0]!;
// Must be an expression statement containing a function
if (rootStatement.type !== 'ExpressionStatement') {
if (rootStatement.type === 'VariableDeclaration') {
return {
valid: false,
error:
'Variable declarations (const, let, var) are not allowed. Use a direct function expression instead.',
};
}
if (rootStatement.type === 'FunctionDeclaration') {
return {
valid: false,
error:
'Function declarations are not allowed. Use an arrow function or function expression instead: (payload) => { ... } or function(payload) { ... }',
};
}
return {
valid: false,
error: 'Code must be a function expression or arrow function',
};
}
const rootExpression = rootStatement.expression;
if (rootExpression.type !== 'ArrowFunctionExpression') {
if (rootExpression.type === 'FunctionExpression') {
return {
valid: false,
error:
'Function expressions are not allowed. Use arrow functions instead: (payload) => { ... }',
};
}
return {
valid: false,
error: 'Code must be an arrow function, e.g.: (payload) => { ... }',
};
}
// Collect all declared identifiers (variables, parameters)
const declaredIdentifiers = collectDeclaredIdentifiers(ast);
let validationError: string | undefined;
// Walk the AST to check for allowed patterns only
walkNode(ast, (node, parent) => {
// Skip if we already found an error
if (validationError) {
return;
}
// Block import/export declarations
if (
node.type === 'ImportDeclaration' ||
node.type === 'ExportDeclaration'
) {
validationError = 'import/export statements are not allowed';
return;
}
// Block function declarations inside the function body
// (FunctionDeclaration creates a named function, not allowed)
if (node.type === 'FunctionDeclaration') {
validationError =
'Named function declarations are not allowed inside the function body.';
return;
}
// Block loops - use array methods like .map(), .filter() instead
if (
node.type === 'WhileStatement' ||
node.type === 'DoWhileStatement' ||
node.type === 'ForStatement' ||
node.type === 'ForInStatement' ||
node.type === 'ForOfStatement'
) {
validationError =
'Loops are not allowed. Use array methods like .map(), .filter(), .reduce() instead.';
return;
}
// Block advanced/dangerous features
if (node.type === 'TryStatement') {
validationError = 'try/catch statements are not allowed';
return;
}
if (node.type === 'ThrowStatement') {
validationError = 'throw statements are not allowed';
return;
}
if (node.type === 'WithStatement') {
validationError = 'with statements are not allowed';
return;
}
if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
validationError = 'Class definitions are not allowed';
return;
}
if (node.type === 'AwaitExpression') {
validationError = 'async/await is not allowed';
return;
}
if (node.type === 'YieldExpression') {
validationError = 'Generators are not allowed';
return;
}
// Block 'this' keyword - arrow functions don't have their own 'this'
// but we block it entirely to prevent any scope leakage
if (node.type === 'ThisExpression') {
validationError =
"'this' keyword is not allowed. Use the payload parameter instead.";
return;
}
// Check identifiers that reference globals
if (node.type === 'Identifier') {
const name = node.name as string;
// Block 'arguments' - not available in arrow functions anyway
// but explicitly block to prevent any confusion
if (name === 'arguments') {
validationError =
"'arguments' is not allowed. Use explicit parameters instead.";
return;
}
// Skip if it's a property key (not a value reference)
if (isPropertyKey(node, parent)) {
return;
}
// Skip if it's a declared local variable/parameter
if (declaredIdentifiers.has(name)) {
return;
}
// Check if it's an allowed global
if (!ALLOWED_GLOBALS.has(name)) {
validationError = `Use of '${name}' is not allowed. Only safe built-in functions are permitted.`;
return;
}
}
// Check method calls on global objects (like Math.random, JSON.parse)
// Handles both regular calls and optional chaining (?.)
if (
node.type === 'CallExpression' ||
node.type === 'OptionalCallExpression'
) {
const callee = node.callee as Record<string, unknown>;
const isMemberExpr =
callee.type === 'MemberExpression' ||
callee.type === 'OptionalMemberExpression';
if (isMemberExpr) {
const obj = callee.object as Record<string, unknown>;
const prop = callee.property as Record<string, unknown>;
const computed = callee.computed as boolean;
// Static method call on global object: Math.random(), JSON.parse()
if (
obj.type === 'Identifier' &&
prop.type === 'Identifier' &&
!computed
) {
const objName = obj.name as string;
const methodName = prop.name as string;
// Check if it's a call on an allowed global object
if (ALLOWED_GLOBALS.has(objName) && ALLOWED_METHODS[objName]) {
if (!ALLOWED_METHODS[objName].has(methodName)) {
validationError = `Method '${objName}.${methodName}' is not allowed. Only safe methods are permitted.`;
return;
}
}
}
// Instance method call: arr.map(), str.toLowerCase(), arr?.map()
// We allow these if the method name is in ALLOWED_INSTANCE_METHODS
if (prop.type === 'Identifier' && !computed) {
const methodName = prop.name as string;
// If calling on something other than an allowed global,
// check if the method is in the allowed instance methods
if (
obj.type !== 'Identifier' ||
!ALLOWED_GLOBALS.has(obj.name as string)
) {
if (!ALLOWED_INSTANCE_METHODS.has(methodName)) {
validationError = `Method '.${methodName}()' is not allowed. Only safe methods are permitted.`;
return;
}
}
}
}
}
// Check 'new' expressions - only allow new Date()
if (node.type === 'NewExpression') {
const callee = node.callee as Record<string, unknown>;
if (callee.type === 'Identifier') {
const name = callee.name as string;
if (name !== 'Date') {
validationError = `'new ${name}()' is not allowed. Only 'new Date()' is permitted.`;
return;
}
}
}
});
if (validationError) {
return { valid: false, error: validationError };
}
return { valid: true };
} catch (error) {
return {
valid: false,
error:
error instanceof Error
? `Parse error: ${error.message}`
: 'Unknown parse error',
};
}
}