feat: improve webhook integration (customized body and headers)
This commit is contained in:
1
packages/js-runtime/index.ts
Normal file
1
packages/js-runtime/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './src/index';
|
||||
21
packages/js-runtime/package.json
Normal file
21
packages/js-runtime/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@openpanel/js-runtime",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
165
packages/js-runtime/src/ast-walker.ts
Normal file
165
packages/js-runtime/src/ast-walker.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { ALLOWED_GLOBALS, ALLOWED_INSTANCE_METHODS, ALLOWED_METHODS } from './constants';
|
||||
|
||||
/**
|
||||
* Simple recursive AST walker that doesn't require @babel/traverse
|
||||
*/
|
||||
export function walkNode(
|
||||
node: unknown,
|
||||
visitor: (
|
||||
node: Record<string, unknown>,
|
||||
parent?: Record<string, unknown>,
|
||||
) => void,
|
||||
parent?: Record<string, unknown>,
|
||||
): void {
|
||||
if (!node || typeof node !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
walkNode(child, visitor, parent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeObj = node as Record<string, unknown>;
|
||||
|
||||
// Only visit AST nodes (they have a 'type' property)
|
||||
if (typeof nodeObj.type === 'string') {
|
||||
visitor(nodeObj, parent);
|
||||
}
|
||||
|
||||
// Recursively walk all properties
|
||||
for (const key of Object.keys(nodeObj)) {
|
||||
const value = nodeObj[key];
|
||||
if (value && typeof value === 'object') {
|
||||
walkNode(value, visitor, nodeObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track declared variables/parameters to know what identifiers are "local"
|
||||
*/
|
||||
export function collectDeclaredIdentifiers(ast: unknown): Set<string> {
|
||||
const declared = new Set<string>();
|
||||
|
||||
walkNode(ast, (node) => {
|
||||
// Variable declarations: const x = ..., let y = ..., var z = ...
|
||||
if (node.type === 'VariableDeclarator') {
|
||||
const id = node.id as Record<string, unknown>;
|
||||
if (id.type === 'Identifier') {
|
||||
declared.add(id.name as string);
|
||||
}
|
||||
// Handle destructuring patterns
|
||||
if (id.type === 'ObjectPattern') {
|
||||
collectPatternIdentifiers(id, declared);
|
||||
}
|
||||
if (id.type === 'ArrayPattern') {
|
||||
collectPatternIdentifiers(id, declared);
|
||||
}
|
||||
}
|
||||
|
||||
// Function parameters
|
||||
if (
|
||||
node.type === 'ArrowFunctionExpression' ||
|
||||
node.type === 'FunctionExpression' ||
|
||||
node.type === 'FunctionDeclaration'
|
||||
) {
|
||||
const params = node.params as Array<Record<string, unknown>>;
|
||||
for (const param of params) {
|
||||
collectPatternIdentifiers(param, declared);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return declared;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect identifiers from destructuring patterns
|
||||
*/
|
||||
function collectPatternIdentifiers(
|
||||
pattern: Record<string, unknown>,
|
||||
declared: Set<string>,
|
||||
): void {
|
||||
if (pattern.type === 'Identifier') {
|
||||
declared.add(pattern.name as string);
|
||||
} else if (pattern.type === 'ObjectPattern') {
|
||||
const properties = pattern.properties as Array<Record<string, unknown>>;
|
||||
for (const prop of properties) {
|
||||
if (prop.type === 'ObjectProperty') {
|
||||
collectPatternIdentifiers(
|
||||
prop.value as Record<string, unknown>,
|
||||
declared,
|
||||
);
|
||||
} else if (prop.type === 'RestElement') {
|
||||
collectPatternIdentifiers(
|
||||
prop.argument as Record<string, unknown>,
|
||||
declared,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (pattern.type === 'ArrayPattern') {
|
||||
const elements = pattern.elements as Array<Record<string, unknown> | null>;
|
||||
for (const elem of elements) {
|
||||
if (elem) {
|
||||
collectPatternIdentifiers(elem, declared);
|
||||
}
|
||||
}
|
||||
} else if (pattern.type === 'RestElement') {
|
||||
collectPatternIdentifiers(
|
||||
pattern.argument as Record<string, unknown>,
|
||||
declared,
|
||||
);
|
||||
} else if (pattern.type === 'AssignmentPattern') {
|
||||
// Default parameter values: (x = 5) => ...
|
||||
collectPatternIdentifiers(
|
||||
pattern.left as Record<string, unknown>,
|
||||
declared,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an identifier is used as a property key (not a value reference)
|
||||
*/
|
||||
export function isPropertyKey(
|
||||
node: Record<string, unknown>,
|
||||
parent?: Record<string, unknown>,
|
||||
): boolean {
|
||||
if (!parent) return false;
|
||||
|
||||
// Property in object literal: { foo: value } - foo is a key
|
||||
if (
|
||||
parent.type === 'ObjectProperty' &&
|
||||
parent.key === node &&
|
||||
!parent.computed
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Property access: obj.foo - foo is a property access, not a global reference
|
||||
if (
|
||||
parent.type === 'MemberExpression' &&
|
||||
parent.property === node &&
|
||||
!parent.computed
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Optional chaining: obj?.foo - foo is a property access
|
||||
if (
|
||||
parent.type === 'OptionalMemberExpression' &&
|
||||
parent.property === node &&
|
||||
!parent.computed
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Arrow function parameter used in callback: t => t.toUpperCase()
|
||||
// The 't' in arrow function is already collected as declared identifier
|
||||
|
||||
return false;
|
||||
}
|
||||
141
packages/js-runtime/src/constants.ts
Normal file
141
packages/js-runtime/src/constants.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Allowed global identifiers - super restricted allowlist
|
||||
*/
|
||||
export const ALLOWED_GLOBALS = new Set([
|
||||
// Basic values for comparisons
|
||||
'undefined',
|
||||
'null',
|
||||
|
||||
// Type coercion functions
|
||||
'parseInt',
|
||||
'parseFloat',
|
||||
'isNaN',
|
||||
'isFinite',
|
||||
|
||||
// Safe built-in objects (for static methods only)
|
||||
'Math',
|
||||
'Date',
|
||||
'JSON',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Allowed methods on built-in objects (static methods)
|
||||
*/
|
||||
export const ALLOWED_METHODS: Record<string, Set<string>> = {
|
||||
// Math methods
|
||||
Math: new Set(['abs', 'ceil', 'floor', 'round', 'min', 'max', 'random']),
|
||||
|
||||
// JSON methods
|
||||
JSON: new Set(['parse', 'stringify']),
|
||||
|
||||
// Date static methods
|
||||
Date: new Set(['now']),
|
||||
};
|
||||
|
||||
/**
|
||||
* Allowed instance methods (methods called on values, not on global objects)
|
||||
* These are safe methods that can be called on any value
|
||||
*/
|
||||
export const ALLOWED_INSTANCE_METHODS = new Set([
|
||||
// Array instance methods
|
||||
'map',
|
||||
'filter',
|
||||
'reduce',
|
||||
'reduceRight',
|
||||
'forEach',
|
||||
'find',
|
||||
'findIndex',
|
||||
'findLast',
|
||||
'findLastIndex',
|
||||
'some',
|
||||
'every',
|
||||
'includes',
|
||||
'indexOf',
|
||||
'lastIndexOf',
|
||||
'slice',
|
||||
'concat',
|
||||
'join',
|
||||
'flat',
|
||||
'flatMap',
|
||||
'sort',
|
||||
'reverse',
|
||||
'fill',
|
||||
'at',
|
||||
'with',
|
||||
'toSorted',
|
||||
'toReversed',
|
||||
'toSpliced',
|
||||
|
||||
// String instance methods
|
||||
'toLowerCase',
|
||||
'toUpperCase',
|
||||
'toLocaleLowerCase',
|
||||
'toLocaleUpperCase',
|
||||
'trim',
|
||||
'trimStart',
|
||||
'trimEnd',
|
||||
'padStart',
|
||||
'padEnd',
|
||||
'repeat',
|
||||
'replace',
|
||||
'replaceAll',
|
||||
'split',
|
||||
'substring',
|
||||
'substr',
|
||||
'charAt',
|
||||
'charCodeAt',
|
||||
'codePointAt',
|
||||
'startsWith',
|
||||
'endsWith',
|
||||
'match',
|
||||
'matchAll',
|
||||
'search',
|
||||
'normalize',
|
||||
'localeCompare',
|
||||
|
||||
// Number instance methods
|
||||
'toFixed',
|
||||
'toPrecision',
|
||||
'toExponential',
|
||||
'toLocaleString',
|
||||
|
||||
// Date instance methods
|
||||
'getTime',
|
||||
'getFullYear',
|
||||
'getMonth',
|
||||
'getDate',
|
||||
'getDay',
|
||||
'getHours',
|
||||
'getMinutes',
|
||||
'getSeconds',
|
||||
'getMilliseconds',
|
||||
'getUTCFullYear',
|
||||
'getUTCMonth',
|
||||
'getUTCDate',
|
||||
'getUTCDay',
|
||||
'getUTCHours',
|
||||
'getUTCMinutes',
|
||||
'getUTCSeconds',
|
||||
'getUTCMilliseconds',
|
||||
'getTimezoneOffset',
|
||||
'toISOString',
|
||||
'toJSON',
|
||||
'toDateString',
|
||||
'toTimeString',
|
||||
'toLocaleDateString',
|
||||
'toLocaleTimeString',
|
||||
'valueOf',
|
||||
|
||||
// Object instance methods
|
||||
'hasOwnProperty',
|
||||
'toString',
|
||||
'valueOf',
|
||||
|
||||
// RegExp instance methods
|
||||
'test',
|
||||
'exec',
|
||||
|
||||
// Common property access (length, etc.)
|
||||
'length',
|
||||
'size',
|
||||
]);
|
||||
31
packages/js-runtime/src/execute.ts
Normal file
31
packages/js-runtime/src/execute.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Executes a JavaScript function template
|
||||
* @param code - JavaScript function code (arrow function or function expression)
|
||||
* @param payload - Payload object to pass to the function
|
||||
* @returns The result of executing the function
|
||||
*/
|
||||
export function execute(
|
||||
code: string,
|
||||
payload: Record<string, unknown>,
|
||||
): unknown {
|
||||
try {
|
||||
// Create the function code that will be executed
|
||||
// 'use strict' ensures 'this' is undefined (not global object)
|
||||
const funcCode = `
|
||||
'use strict';
|
||||
return (${code})(payload);
|
||||
`;
|
||||
|
||||
// Create function with safe globals in scope
|
||||
const func = new Function('payload', funcCode);
|
||||
|
||||
// Execute the function
|
||||
return func(payload);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Error executing JavaScript template: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
2
packages/js-runtime/src/index.ts
Normal file
2
packages/js-runtime/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { validate } from './validate';
|
||||
export { execute } from './execute';
|
||||
332
packages/js-runtime/src/validate.test.ts
Normal file
332
packages/js-runtime/src/validate.test.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { execute, validate } from './index';
|
||||
|
||||
describe('validate', () => {
|
||||
describe('Valid templates', () => {
|
||||
it('should accept arrow function', () => {
|
||||
const result = validate('(payload) => ({ event: payload.name })');
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject function expression', () => {
|
||||
// Function expressions are not allowed - only arrow functions
|
||||
const result = validate(
|
||||
'(function(payload) { return { event: payload.name }; })',
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('arrow functions');
|
||||
});
|
||||
|
||||
it('should accept complex transformations', () => {
|
||||
const code = `(payload) => ({
|
||||
event: payload.name,
|
||||
user: payload.profileId,
|
||||
data: payload.properties,
|
||||
timestamp: new Date(payload.createdAt).toISOString()
|
||||
})`;
|
||||
const result = validate(code);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept array operations', () => {
|
||||
const code = `(payload) => ({
|
||||
tags: payload.tags?.map(t => t.toUpperCase()) || []
|
||||
})`;
|
||||
const result = validate(code);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Blocked operations', () => {
|
||||
it('should block fetch calls', () => {
|
||||
const result = validate('(payload) => fetch("https://evil.com")');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('fetch');
|
||||
});
|
||||
|
||||
it('should block XMLHttpRequest', () => {
|
||||
const result = validate('(payload) => new XMLHttpRequest()');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('XMLHttpRequest');
|
||||
});
|
||||
|
||||
it('should block require calls', () => {
|
||||
const result = validate('(payload) => require("fs")');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('require');
|
||||
});
|
||||
|
||||
it('should block import statements', () => {
|
||||
const result = validate('import fs from "fs"; (payload) => ({})');
|
||||
expect(result.valid).toBe(false);
|
||||
// Import statements cause multiple statements error first
|
||||
expect(result.error).toContain('single function');
|
||||
});
|
||||
|
||||
it('should block eval calls', () => {
|
||||
const result = validate('(payload) => eval("evil")');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('eval');
|
||||
});
|
||||
|
||||
it('should block setTimeout', () => {
|
||||
const result = validate('(payload) => setTimeout(() => {}, 1000)');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('setTimeout');
|
||||
});
|
||||
|
||||
it('should block process access', () => {
|
||||
const result = validate('(payload) => process.env.SECRET');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('process');
|
||||
});
|
||||
|
||||
it('should block while loops', () => {
|
||||
const result = validate(
|
||||
'(payload) => { while(true) {} return payload; }',
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Loops are not allowed');
|
||||
});
|
||||
|
||||
it('should block for loops', () => {
|
||||
const result = validate(
|
||||
'(payload) => { for(let i = 0; i < 10; i++) {} return payload; }',
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Loops are not allowed');
|
||||
});
|
||||
|
||||
it('should block try/catch', () => {
|
||||
const result = validate(
|
||||
'(payload) => { try { return payload; } catch(e) {} }',
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('try/catch');
|
||||
});
|
||||
|
||||
it('should block async/await', () => {
|
||||
const result = validate(
|
||||
'async (payload) => { await something(); return payload; }',
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('async/await');
|
||||
});
|
||||
|
||||
it('should block classes', () => {
|
||||
const result = validate('(payload) => { class Foo {} return payload; }');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Class');
|
||||
});
|
||||
|
||||
it('should block new Array()', () => {
|
||||
const result = validate('(payload) => new Array(10)');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('new Array()');
|
||||
});
|
||||
|
||||
it('should block new Object()', () => {
|
||||
const result = validate('(payload) => new Object()');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('new Object()');
|
||||
});
|
||||
|
||||
it('should allow new Date()', () => {
|
||||
const result = validate(
|
||||
'(payload) => ({ timestamp: new Date().toISOString() })',
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid syntax', () => {
|
||||
it('should reject non-function code', () => {
|
||||
const result = validate('const x = 1;');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('function');
|
||||
});
|
||||
|
||||
it('should reject invalid JavaScript', () => {
|
||||
const result = validate('(payload) => { invalid syntax }');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Parse error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
const basePayload = {
|
||||
name: 'page_view',
|
||||
profileId: 'user-123',
|
||||
country: 'US',
|
||||
city: 'New York',
|
||||
device: 'desktop',
|
||||
os: 'Windows',
|
||||
browser: 'Chrome',
|
||||
longitude: -73.935242,
|
||||
latitude: 40.73061,
|
||||
createdAt: '2024-01-15T10:30:00Z',
|
||||
properties: {
|
||||
plan: 'premium',
|
||||
userId: 'user-456',
|
||||
metadata: {
|
||||
source: 'web',
|
||||
campaign: 'summer-sale',
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
email: 'john@example.com',
|
||||
},
|
||||
tags: ['tag1', 'tag2'],
|
||||
};
|
||||
|
||||
describe('Basic transformations', () => {
|
||||
it('should execute simple arrow function', () => {
|
||||
const code = '(payload) => ({ event: payload.name })';
|
||||
const result = execute(code, basePayload);
|
||||
expect(result).toEqual({ event: 'page_view' });
|
||||
});
|
||||
|
||||
it('should access nested properties', () => {
|
||||
const code = '(payload) => ({ plan: payload.properties.plan })';
|
||||
const result = execute(code, basePayload);
|
||||
expect(result).toEqual({ plan: 'premium' });
|
||||
});
|
||||
|
||||
it('should handle multiple properties', () => {
|
||||
const code = `(payload) => ({
|
||||
event: payload.name,
|
||||
user: payload.profileId,
|
||||
location: payload.city
|
||||
})`;
|
||||
const result = execute(code, basePayload);
|
||||
expect(result).toEqual({
|
||||
event: 'page_view',
|
||||
user: 'user-123',
|
||||
location: 'New York',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Date operations', () => {
|
||||
it('should format dates', () => {
|
||||
const code = `(payload) => ({
|
||||
timestamp: new Date(payload.createdAt).toISOString()
|
||||
})`;
|
||||
const result = execute(code, basePayload);
|
||||
expect(result).toHaveProperty('timestamp');
|
||||
expect((result as { timestamp: string }).timestamp).toBe(
|
||||
'2024-01-15T10:30:00.000Z',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Array operations', () => {
|
||||
it('should transform arrays', () => {
|
||||
const code = `(payload) => ({
|
||||
tags: payload.tags?.map(t => t.toUpperCase()) || []
|
||||
})`;
|
||||
const result = execute(code, basePayload);
|
||||
expect(result).toEqual({ tags: ['TAG1', 'TAG2'] });
|
||||
});
|
||||
|
||||
it('should filter arrays', () => {
|
||||
const code = `(payload) => ({
|
||||
filtered: payload.tags?.filter(t => t.includes('tag1')) || []
|
||||
})`;
|
||||
const result = execute(code, basePayload);
|
||||
expect(result).toEqual({ filtered: ['tag1'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('String operations', () => {
|
||||
it('should use template literals', () => {
|
||||
const code = `(payload) => ({
|
||||
location: \`\${payload.city}, \${payload.country}\`
|
||||
})`;
|
||||
const result = execute(code, basePayload);
|
||||
expect(result).toEqual({ location: 'New York, US' });
|
||||
});
|
||||
|
||||
it('should use string methods', () => {
|
||||
const code = `(payload) => ({
|
||||
upperName: payload.name.toUpperCase()
|
||||
})`;
|
||||
const result = execute(code, basePayload);
|
||||
expect(result).toEqual({ upperName: 'PAGE_VIEW' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Math operations', () => {
|
||||
it('should use Math functions', () => {
|
||||
const code = `(payload) => ({
|
||||
roundedLng: Math.round(payload.longitude)
|
||||
})`;
|
||||
const result = execute(code, basePayload);
|
||||
expect(result).toEqual({ roundedLng: -74 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Conditional logic', () => {
|
||||
it('should handle ternary operators', () => {
|
||||
const code = `(payload) => ({
|
||||
plan: payload.properties?.plan || 'free'
|
||||
})`;
|
||||
const result = execute(code, basePayload);
|
||||
expect(result).toEqual({ plan: 'premium' });
|
||||
});
|
||||
|
||||
it('should handle if conditions', () => {
|
||||
const code = `(payload) => {
|
||||
const result = { event: payload.name };
|
||||
if (payload.properties?.plan === 'premium') {
|
||||
result.plan = 'premium';
|
||||
}
|
||||
return result;
|
||||
}`;
|
||||
const result = execute(code, basePayload);
|
||||
expect(result).toEqual({ event: 'page_view', plan: 'premium' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex transformations', () => {
|
||||
it('should handle nested object construction', () => {
|
||||
const code = `(payload) => ({
|
||||
event: payload.name,
|
||||
user: {
|
||||
id: payload.profileId,
|
||||
name: \`\${payload.profile?.firstName} \${payload.profile?.lastName}\`
|
||||
},
|
||||
data: payload.properties,
|
||||
meta: {
|
||||
location: \`\${payload.city}, \${payload.country}\`,
|
||||
device: payload.device
|
||||
}
|
||||
})`;
|
||||
const result = execute(code, basePayload);
|
||||
expect(result).toEqual({
|
||||
event: 'page_view',
|
||||
user: {
|
||||
id: 'user-123',
|
||||
name: 'John Doe',
|
||||
},
|
||||
data: basePayload.properties,
|
||||
meta: {
|
||||
location: 'New York, US',
|
||||
device: 'desktop',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should handle runtime errors gracefully', () => {
|
||||
const code = '(payload) => payload.nonexistent.property';
|
||||
expect(() => {
|
||||
execute(code, basePayload);
|
||||
}).toThrow('Error executing JavaScript template');
|
||||
});
|
||||
});
|
||||
});
|
||||
280
packages/js-runtime/src/validate.ts
Normal file
280
packages/js-runtime/src/validate.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
12
packages/js-runtime/tsconfig.json
Normal file
12
packages/js-runtime/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@openpanel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
"@openpanel/db": "workspace:*",
|
||||
"@openpanel/email": "workspace:*",
|
||||
"@openpanel/integrations": "workspace:^",
|
||||
"@openpanel/js-runtime": "workspace:*",
|
||||
"@openpanel/payments": "workspace:^",
|
||||
"@openpanel/redis": "workspace:*",
|
||||
"@openpanel/validation": "workspace:*",
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
zCreateWebhookIntegration,
|
||||
} from '@openpanel/validation';
|
||||
import { getOrganizationAccess } from '../access';
|
||||
import { TRPCAccessError } from '../errors';
|
||||
import { TRPCAccessError, TRPCBadRequestError } from '../errors';
|
||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||
import { validate as validateJavaScriptTemplate } from '@openpanel/js-runtime';
|
||||
|
||||
export const integrationRouter = createTRPCRouter({
|
||||
get: protectedProcedure
|
||||
@@ -93,6 +94,22 @@ export const integrationRouter = createTRPCRouter({
|
||||
createOrUpdate: protectedProcedure
|
||||
.input(z.union([zCreateDiscordIntegration, zCreateWebhookIntegration]))
|
||||
.mutation(async ({ input }) => {
|
||||
// Validate JavaScript template if mode is javascript
|
||||
if (
|
||||
input.config.type === 'webhook' &&
|
||||
input.config.mode === 'javascript' &&
|
||||
input.config.javascriptTemplate
|
||||
) {
|
||||
const validation = validateJavaScriptTemplate(
|
||||
input.config.javascriptTemplate,
|
||||
);
|
||||
if (!validation.valid) {
|
||||
throw TRPCBadRequestError(
|
||||
`Invalid JavaScript template: ${validation.error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.id) {
|
||||
return db.integration.update({
|
||||
where: {
|
||||
|
||||
@@ -369,6 +369,8 @@ export const zWebhookConfig = z.object({
|
||||
url: z.string().url(),
|
||||
headers: z.record(z.string()),
|
||||
payload: z.record(z.string(), z.unknown()).optional(),
|
||||
mode: z.enum(['message', 'javascript']).default('message'),
|
||||
javascriptTemplate: z.string().optional(),
|
||||
});
|
||||
export type IWebhookConfig = z.infer<typeof zWebhookConfig>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user