mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-11 07:04:58 -05:00
improvement(rce): updated rce to use isolate pkg for RCE (#2385)
* improvement(rce): updated rce to use isolate pkg for RCE * lazy load isolate instance * update zoom docs
This commit is contained in:
@@ -7,10 +7,80 @@ import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockRequest } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
const mockCreateContext = vi.fn()
|
||||
const mockRunInContext = vi.fn()
|
||||
const mockScript = vi.fn()
|
||||
const mockExecuteInE2B = vi.fn()
|
||||
vi.mock('@/lib/execution/isolated-vm', () => ({
|
||||
executeInIsolatedVM: vi.fn().mockImplementation(async (req) => {
|
||||
const { code, params, envVars, contextVariables } = req
|
||||
const stdoutChunks: string[] = []
|
||||
|
||||
const mockConsole = {
|
||||
log: (...args: unknown[]) => {
|
||||
stdoutChunks.push(
|
||||
`${args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ')}\n`
|
||||
)
|
||||
},
|
||||
error: (...args: unknown[]) => {
|
||||
stdoutChunks.push(
|
||||
'ERROR: ' +
|
||||
args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ') +
|
||||
'\n'
|
||||
)
|
||||
},
|
||||
warn: (...args: unknown[]) => mockConsole.log('WARN:', ...args),
|
||||
info: (...args: unknown[]) => mockConsole.log(...args),
|
||||
}
|
||||
|
||||
try {
|
||||
const escapePattern = /this\.constructor\.constructor|\.constructor\s*\(/
|
||||
if (escapePattern.test(code)) {
|
||||
return { result: undefined, stdout: '' }
|
||||
}
|
||||
|
||||
const context: Record<string, unknown> = {
|
||||
console: mockConsole,
|
||||
params,
|
||||
environmentVariables: envVars,
|
||||
...contextVariables,
|
||||
process: undefined,
|
||||
require: undefined,
|
||||
module: undefined,
|
||||
exports: undefined,
|
||||
__dirname: undefined,
|
||||
__filename: undefined,
|
||||
fetch: async () => {
|
||||
throw new Error('fetch not implemented in test mock')
|
||||
},
|
||||
}
|
||||
|
||||
const paramNames = Object.keys(context)
|
||||
const paramValues = Object.values(context)
|
||||
|
||||
const wrappedCode = `
|
||||
return (async () => {
|
||||
${code}
|
||||
})();
|
||||
`
|
||||
|
||||
const fn = new Function(...paramNames, wrappedCode)
|
||||
const result = await fn(...paramValues)
|
||||
|
||||
return {
|
||||
result,
|
||||
stdout: stdoutChunks.join(''),
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error
|
||||
return {
|
||||
result: null,
|
||||
stdout: stdoutChunks.join(''),
|
||||
error: {
|
||||
message: err.message || String(error),
|
||||
name: err.name || 'Error',
|
||||
stack: err.stack,
|
||||
},
|
||||
}
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
@@ -21,35 +91,20 @@ vi.mock('@/lib/logs/console/logger', () => ({
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('vm', () => ({
|
||||
createContext: vi.fn(),
|
||||
Script: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/execution/e2b', () => ({
|
||||
executeInE2B: vi.fn(),
|
||||
}))
|
||||
|
||||
import { createContext, Script } from 'vm'
|
||||
import { validateProxyUrl } from '@/lib/core/security/input-validation'
|
||||
import { executeInE2B } from '@/lib/execution/e2b'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { POST } from './route'
|
||||
|
||||
const mockedCreateContext = vi.mocked(createContext)
|
||||
const mockedScript = vi.mocked(Script)
|
||||
const mockedExecuteInE2B = vi.mocked(executeInE2B)
|
||||
const mockedCreateLogger = vi.mocked(createLogger)
|
||||
|
||||
describe('Function Execute API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockedCreateContext.mockReturnValue({})
|
||||
mockRunInContext.mockResolvedValue('vm success')
|
||||
mockedScript.mockImplementation((): any => ({
|
||||
runInContext: mockRunInContext,
|
||||
}))
|
||||
mockedExecuteInE2B.mockResolvedValue({
|
||||
result: 'e2b success',
|
||||
stdout: 'e2b output',
|
||||
@@ -62,19 +117,77 @@ describe('Function Execute API Route', () => {
|
||||
})
|
||||
|
||||
describe('Security Tests', () => {
|
||||
it.concurrent('should create secure fetch in VM context', async () => {
|
||||
it.concurrent('should use isolated-vm for secure sandboxed execution', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return "test"',
|
||||
})
|
||||
|
||||
await POST(req)
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(mockedCreateContext).toHaveBeenCalled()
|
||||
const contextArgs = mockedCreateContext.mock.calls[0][0]
|
||||
expect(contextArgs).toHaveProperty('fetch')
|
||||
expect(typeof (contextArgs as any).fetch).toBe('function')
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.output.result).toBe('test')
|
||||
})
|
||||
|
||||
expect((contextArgs as any).fetch?.name).toBe('secureFetch')
|
||||
it.concurrent('should prevent VM escape via constructor chain', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return this.constructor.constructor("return process")().env',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
if (response.status === 500) {
|
||||
expect(data.success).toBe(false)
|
||||
} else {
|
||||
const result = data.output?.result
|
||||
expect(result === undefined || result === null).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('should prevent access to require via constructor chain', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: `
|
||||
const proc = this.constructor.constructor("return process")();
|
||||
const fs = proc.mainModule.require("fs");
|
||||
return fs.readFileSync("/etc/passwd", "utf8");
|
||||
`,
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
if (response.status === 200) {
|
||||
const result = data.output?.result
|
||||
if (result !== undefined && result !== null && typeof result === 'string') {
|
||||
expect(result).not.toContain('root:')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it.concurrent('should not expose process object', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return typeof process',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.output.result).toBe('undefined')
|
||||
})
|
||||
|
||||
it.concurrent('should not expose require function', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return typeof require',
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.output.result).toBe('undefined')
|
||||
})
|
||||
|
||||
it.concurrent('should block SSRF attacks through secure fetch wrapper', async () => {
|
||||
@@ -113,6 +226,20 @@ describe('Function Execute API Route', () => {
|
||||
expect(data.output).toHaveProperty('executionTime')
|
||||
})
|
||||
|
||||
it.concurrent('should return computed result for multi-line code', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'const a = 1;\nconst b = 2;\nconst c = 3;\nconst d = 4;\nreturn a + b + c + d;',
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.output.result).toBe(10)
|
||||
})
|
||||
|
||||
it.concurrent('should handle missing code parameter', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
timeout: 5000,
|
||||
@@ -312,20 +439,6 @@ describe('Function Execute API Route', () => {
|
||||
|
||||
describe('Enhanced Error Handling', () => {
|
||||
it('should provide detailed syntax error with line content', async () => {
|
||||
const syntaxError = new Error('Invalid or unexpected token')
|
||||
syntaxError.name = 'SyntaxError'
|
||||
syntaxError.stack = `user-function.js:5
|
||||
description: "This has a missing closing quote
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
SyntaxError: Invalid or unexpected token
|
||||
at new Script (node:vm:117:7)
|
||||
at POST (/path/to/route.ts:123:24)`
|
||||
|
||||
mockedScript.mockImplementationOnce(() => {
|
||||
throw syntaxError
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'const obj = {\n name: "test",\n description: "This has a missing closing quote\n};\nreturn obj;',
|
||||
timeout: 5000,
|
||||
@@ -336,28 +449,10 @@ SyntaxError: Invalid or unexpected token
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('Syntax Error')
|
||||
expect(data.error).toContain('Line 3')
|
||||
expect(data.error).toContain('description: "This has a missing closing quote')
|
||||
expect(data.error).toContain('Invalid or unexpected token')
|
||||
expect(data.error).toContain('(Check for missing quotes, brackets, or semicolons)')
|
||||
|
||||
expect(data.debug).toBeDefined()
|
||||
expect(data.debug.line).toBe(3)
|
||||
expect(data.debug.errorType).toBe('SyntaxError')
|
||||
expect(data.debug.lineContent).toBe('description: "This has a missing closing quote')
|
||||
expect(data.error).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should provide detailed runtime error with line and column', async () => {
|
||||
const runtimeError = new Error("Cannot read properties of null (reading 'someMethod')")
|
||||
runtimeError.name = 'TypeError'
|
||||
runtimeError.stack = `TypeError: Cannot read properties of null (reading 'someMethod')
|
||||
at user-function.js:4:16
|
||||
at user-function.js:9:3
|
||||
at Script.runInContext (node:vm:147:14)`
|
||||
|
||||
mockRunInContext.mockRejectedValueOnce(runtimeError)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'const obj = null;\nreturn obj.someMethod();',
|
||||
timeout: 5000,
|
||||
@@ -369,26 +464,10 @@ SyntaxError: Invalid or unexpected token
|
||||
expect(response.status).toBe(500)
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('Type Error')
|
||||
expect(data.error).toContain('Line 2')
|
||||
expect(data.error).toContain('return obj.someMethod();')
|
||||
expect(data.error).toContain('Cannot read properties of null')
|
||||
|
||||
expect(data.debug).toBeDefined()
|
||||
expect(data.debug.line).toBe(2)
|
||||
expect(data.debug.column).toBe(16)
|
||||
expect(data.debug.errorType).toBe('TypeError')
|
||||
expect(data.debug.lineContent).toBe('return obj.someMethod();')
|
||||
})
|
||||
|
||||
it('should handle ReferenceError with enhanced details', async () => {
|
||||
const referenceError = new Error('undefinedVariable is not defined')
|
||||
referenceError.name = 'ReferenceError'
|
||||
referenceError.stack = `ReferenceError: undefinedVariable is not defined
|
||||
at user-function.js:4:8
|
||||
at Script.runInContext (node:vm:147:14)`
|
||||
|
||||
mockRunInContext.mockRejectedValueOnce(referenceError)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'const x = 42;\nreturn undefinedVariable + x;',
|
||||
timeout: 5000,
|
||||
@@ -400,21 +479,12 @@ SyntaxError: Invalid or unexpected token
|
||||
expect(response.status).toBe(500)
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('Reference Error')
|
||||
expect(data.error).toContain('Line 2')
|
||||
expect(data.error).toContain('return undefinedVariable + x;')
|
||||
expect(data.error).toContain('undefinedVariable is not defined')
|
||||
})
|
||||
|
||||
it('should handle errors without line content gracefully', async () => {
|
||||
const genericError = new Error('Generic error without stack trace')
|
||||
genericError.name = 'Error'
|
||||
|
||||
mockedScript.mockImplementationOnce(() => {
|
||||
throw genericError
|
||||
})
|
||||
|
||||
it('should handle thrown errors gracefully', async () => {
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'return "test";',
|
||||
code: 'throw new Error("Custom error message");',
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
@@ -423,51 +493,10 @@ SyntaxError: Invalid or unexpected token
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toBe('Generic error without stack trace')
|
||||
|
||||
expect(data.debug).toBeDefined()
|
||||
expect(data.debug.errorType).toBe('Error')
|
||||
expect(data.debug.line).toBeUndefined()
|
||||
expect(data.debug.lineContent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should extract line numbers from different stack trace formats', async () => {
|
||||
const testError = new Error('Test error')
|
||||
testError.name = 'Error'
|
||||
testError.stack = `Error: Test error
|
||||
at user-function.js:7:25
|
||||
at async function
|
||||
at Script.runInContext (node:vm:147:14)`
|
||||
|
||||
mockedScript.mockImplementationOnce(() => {
|
||||
throw testError
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'const a = 1;\nconst b = 2;\nconst c = 3;\nconst d = 4;\nreturn a + b + c + d;',
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(data.success).toBe(false)
|
||||
|
||||
expect(data.debug.line).toBe(5)
|
||||
expect(data.debug.column).toBe(25)
|
||||
expect(data.debug.lineContent).toBe('return a + b + c + d;')
|
||||
expect(data.error).toContain('Custom error message')
|
||||
})
|
||||
|
||||
it.concurrent('should provide helpful suggestions for common syntax errors', async () => {
|
||||
const syntaxError = new Error('Unexpected end of input')
|
||||
syntaxError.name = 'SyntaxError'
|
||||
syntaxError.stack = 'user-function.js:4\nSyntaxError: Unexpected end of input'
|
||||
|
||||
mockedScript.mockImplementationOnce(() => {
|
||||
throw syntaxError
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
code: 'const obj = {\n name: "test"\n// Missing closing brace',
|
||||
timeout: 5000,
|
||||
@@ -478,9 +507,7 @@ SyntaxError: Invalid or unexpected token
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('Syntax Error')
|
||||
expect(data.error).toContain('Unexpected end of input')
|
||||
expect(data.error).toContain('(Check for missing closing brackets or braces)')
|
||||
expect(data.error).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createContext, Script } from 'vm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { isE2bEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { validateProxyUrl } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { executeInE2B } from '@/lib/execution/e2b'
|
||||
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
|
||||
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -13,30 +12,6 @@ export const MAX_DURATION = 210
|
||||
|
||||
const logger = createLogger('FunctionExecuteAPI')
|
||||
|
||||
function createSecureFetch(requestId: string) {
|
||||
const originalFetch = (globalThis as any).fetch || require('node-fetch').default
|
||||
|
||||
return async function secureFetch(input: any, init?: any) {
|
||||
const url = typeof input === 'string' ? input : input?.url || input
|
||||
|
||||
if (!url || typeof url !== 'string') {
|
||||
throw new Error('Invalid URL provided to fetch')
|
||||
}
|
||||
|
||||
const validation = validateProxyUrl(url)
|
||||
if (!validation.isValid) {
|
||||
logger.warn(`[${requestId}] Blocked fetch request due to SSRF validation`, {
|
||||
url: url.substring(0, 100),
|
||||
error: validation.error,
|
||||
})
|
||||
throw new Error(`Security Error: ${validation.error}`)
|
||||
}
|
||||
|
||||
return originalFetch(input, init)
|
||||
}
|
||||
}
|
||||
|
||||
// Constants for E2B code wrapping line counts
|
||||
const E2B_JS_WRAPPER_LINES = 3 // Lines before user code: ';(async () => {', ' try {', ' const __sim_result = await (async () => {'
|
||||
const E2B_PYTHON_WRAPPER_LINES = 1 // Lines before user code: 'def __sim_main__():'
|
||||
|
||||
@@ -899,28 +874,7 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
const executionMethod = 'vm'
|
||||
const context = createContext({
|
||||
params: executionParams,
|
||||
environmentVariables: envVars,
|
||||
...contextVariables,
|
||||
fetch: createSecureFetch(requestId),
|
||||
console: {
|
||||
log: (...args: any[]) => {
|
||||
const logMessage = `${args
|
||||
.map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg)))
|
||||
.join(' ')}\n`
|
||||
stdout += logMessage
|
||||
},
|
||||
error: (...args: any[]) => {
|
||||
const errorMessage = `${args
|
||||
.map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg)))
|
||||
.join(' ')}\n`
|
||||
logger.error(`[${requestId}] Code Console Error: ${errorMessage}`)
|
||||
stdout += `ERROR: ${errorMessage}`
|
||||
},
|
||||
},
|
||||
})
|
||||
const executionMethod = 'isolated-vm'
|
||||
|
||||
const wrapperLines = ['(async () => {', ' try {']
|
||||
if (isCustomTool) {
|
||||
@@ -930,36 +884,84 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
}
|
||||
userCodeStartLine = wrapperLines.length + 1
|
||||
const fullScript = [
|
||||
...wrapperLines,
|
||||
` ${resolvedCode.split('\n').join('\n ')}`,
|
||||
' } catch (error) {',
|
||||
' console.error(error);',
|
||||
' throw error;',
|
||||
' }',
|
||||
'})()',
|
||||
].join('\n')
|
||||
|
||||
const script = new Script(fullScript, {
|
||||
filename: 'user-function.js',
|
||||
lineOffset: 0,
|
||||
columnOffset: 0,
|
||||
})
|
||||
let codeToExecute = resolvedCode
|
||||
if (isCustomTool) {
|
||||
const paramDestructuring = Object.keys(executionParams)
|
||||
.map((key) => `const ${key} = params.${key};`)
|
||||
.join('\n')
|
||||
codeToExecute = `${paramDestructuring}\n${resolvedCode}`
|
||||
}
|
||||
|
||||
const result = await script.runInContext(context, {
|
||||
timeout,
|
||||
displayErrors: true,
|
||||
breakOnSigint: true,
|
||||
const isolatedResult = await executeInIsolatedVM({
|
||||
code: codeToExecute,
|
||||
params: executionParams,
|
||||
envVars,
|
||||
contextVariables,
|
||||
timeoutMs: timeout,
|
||||
requestId,
|
||||
})
|
||||
|
||||
const executionTime = Date.now() - startTime
|
||||
|
||||
if (isolatedResult.error) {
|
||||
const errorObj = {
|
||||
message: isolatedResult.error.message,
|
||||
name: isolatedResult.error.name,
|
||||
stack: isolatedResult.error.stack,
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Function execution failed in isolated-vm`, {
|
||||
error: isolatedResult.error,
|
||||
executionTime,
|
||||
})
|
||||
|
||||
const enhancedError = extractEnhancedError(errorObj, userCodeStartLine, resolvedCode)
|
||||
const userFriendlyErrorMessage = createUserFriendlyErrorMessage(
|
||||
enhancedError,
|
||||
requestId,
|
||||
resolvedCode
|
||||
)
|
||||
|
||||
logger.error(`[${requestId}] Enhanced error details`, {
|
||||
originalMessage: errorObj.message,
|
||||
enhancedMessage: userFriendlyErrorMessage,
|
||||
line: enhancedError.line,
|
||||
column: enhancedError.column,
|
||||
lineContent: enhancedError.lineContent,
|
||||
errorType: enhancedError.name,
|
||||
userCodeStartLine,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: userFriendlyErrorMessage,
|
||||
output: {
|
||||
result: null,
|
||||
stdout: cleanStdout(isolatedResult.stdout),
|
||||
executionTime,
|
||||
},
|
||||
debug: {
|
||||
line: enhancedError.line,
|
||||
column: enhancedError.column,
|
||||
errorType: enhancedError.name,
|
||||
lineContent: enhancedError.lineContent,
|
||||
stack: enhancedError.stack,
|
||||
},
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
stdout = isolatedResult.stdout
|
||||
logger.info(`[${requestId}] Function executed successfully using ${executionMethod}`, {
|
||||
executionTime,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { result, stdout: cleanStdout(stdout), executionTime },
|
||||
output: { result: isolatedResult.result, stdout: cleanStdout(stdout), executionTime },
|
||||
})
|
||||
} catch (error: any) {
|
||||
const executionTime = Date.now() - startTime
|
||||
|
||||
Reference in New Issue
Block a user