mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
* fix(executor): escape newline characters in condition expression strings Unescaped newline/carriage-return characters in resolved string values cause unterminated string literals in generated JS, crashing condition evaluation with a SyntaxError. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(security): prevent ReDoS in guardrails regex validation Add safe-regex2 to reject catastrophic backtracking patterns before execution and cap input length at 10k characters. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(security): SSRF localhost hardening and regex DoS protection Block localhost/loopback URLs in hosted environments using isHosted flag instead of allowHttp. Add safe-regex2 validation and input length limits to regex guardrails to prevent catastrophic backtracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(security): validate regex syntax before safety check Move new RegExp() before safe() so invalid patterns get a proper syntax error instead of a misleading "catastrophic backtracking" message. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(security): address PR review feedback - Hoist isLocalhost && isHosted guard to single early-return before protocol checks, removing redundant duplicate block - Move regex syntax validation (new RegExp) before safe-regex2 check so invalid patterns get proper syntax error instead of misleading "catastrophic backtracking" message * fix(security): remove input length cap from regex validation The 10k character cap would block legitimate guardrail checks on long LLM outputs. Input length doesn't affect ReDoS risk — the safe-regex2 pattern check already prevents catastrophic backtracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(tests): mock isHosted in input-validation and function-execute tests Tests that assert self-hosted localhost behavior need isHosted=false, which is not guaranteed in CI where NEXT_PUBLIC_APP_URL is set to the hosted domain. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
281 lines
8.0 KiB
TypeScript
281 lines
8.0 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { BlockType } from '@/executor/constants'
|
|
import type { ExecutionState, LoopScope } from '@/executor/execution/state'
|
|
import type { ExecutionContext } from '@/executor/types'
|
|
import { createEnvVarPattern, replaceValidReferences } from '@/executor/utils/reference-validation'
|
|
import { BlockResolver } from '@/executor/variables/resolvers/block'
|
|
import { EnvResolver } from '@/executor/variables/resolvers/env'
|
|
import { LoopResolver } from '@/executor/variables/resolvers/loop'
|
|
import { ParallelResolver } from '@/executor/variables/resolvers/parallel'
|
|
import {
|
|
RESOLVED_EMPTY,
|
|
type ResolutionContext,
|
|
type Resolver,
|
|
} from '@/executor/variables/resolvers/reference'
|
|
import { WorkflowResolver } from '@/executor/variables/resolvers/workflow'
|
|
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
|
|
|
const logger = createLogger('VariableResolver')
|
|
|
|
export class VariableResolver {
|
|
private resolvers: Resolver[]
|
|
private blockResolver: BlockResolver
|
|
|
|
constructor(
|
|
workflow: SerializedWorkflow,
|
|
workflowVariables: Record<string, any>,
|
|
private state: ExecutionState
|
|
) {
|
|
this.blockResolver = new BlockResolver(workflow)
|
|
this.resolvers = [
|
|
new LoopResolver(workflow),
|
|
new ParallelResolver(workflow),
|
|
new WorkflowResolver(workflowVariables),
|
|
new EnvResolver(),
|
|
this.blockResolver,
|
|
]
|
|
}
|
|
|
|
resolveInputs(
|
|
ctx: ExecutionContext,
|
|
currentNodeId: string,
|
|
params: Record<string, any>,
|
|
block?: SerializedBlock
|
|
): Record<string, any> {
|
|
if (!params) {
|
|
return {}
|
|
}
|
|
const resolved: Record<string, any> = {}
|
|
|
|
const isConditionBlock = block?.metadata?.id === BlockType.CONDITION
|
|
if (isConditionBlock && typeof params.conditions === 'string') {
|
|
try {
|
|
const parsed = JSON.parse(params.conditions)
|
|
if (Array.isArray(parsed)) {
|
|
resolved.conditions = parsed.map((cond: any) => ({
|
|
...cond,
|
|
value:
|
|
typeof cond.value === 'string'
|
|
? this.resolveTemplateWithoutConditionFormatting(ctx, currentNodeId, cond.value)
|
|
: cond.value,
|
|
}))
|
|
} else {
|
|
resolved.conditions = this.resolveValue(
|
|
ctx,
|
|
currentNodeId,
|
|
params.conditions,
|
|
undefined,
|
|
block
|
|
)
|
|
}
|
|
} catch (parseError) {
|
|
logger.warn('Failed to parse conditions JSON, falling back to normal resolution', {
|
|
error: parseError,
|
|
conditions: params.conditions,
|
|
})
|
|
resolved.conditions = this.resolveValue(
|
|
ctx,
|
|
currentNodeId,
|
|
params.conditions,
|
|
undefined,
|
|
block
|
|
)
|
|
}
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (isConditionBlock && key === 'conditions') {
|
|
continue
|
|
}
|
|
resolved[key] = this.resolveValue(ctx, currentNodeId, value, undefined, block)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
resolveSingleReference(
|
|
ctx: ExecutionContext,
|
|
currentNodeId: string,
|
|
reference: string,
|
|
loopScope?: LoopScope
|
|
): any {
|
|
if (typeof reference === 'string') {
|
|
const trimmed = reference.trim()
|
|
if (/^<[^<>]+>$/.test(trimmed)) {
|
|
const resolutionContext: ResolutionContext = {
|
|
executionContext: ctx,
|
|
executionState: this.state,
|
|
currentNodeId,
|
|
loopScope,
|
|
}
|
|
|
|
const result = this.resolveReference(trimmed, resolutionContext)
|
|
if (result === RESOLVED_EMPTY) {
|
|
return null
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
return this.resolveValue(ctx, currentNodeId, reference, loopScope)
|
|
}
|
|
|
|
private resolveValue(
|
|
ctx: ExecutionContext,
|
|
currentNodeId: string,
|
|
value: any,
|
|
loopScope?: LoopScope,
|
|
block?: SerializedBlock
|
|
): any {
|
|
if (value === null || value === undefined) {
|
|
return value
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
return value.map((v) => this.resolveValue(ctx, currentNodeId, v, loopScope, block))
|
|
}
|
|
|
|
if (typeof value === 'object') {
|
|
return Object.entries(value).reduce(
|
|
(acc, [key, val]) => ({
|
|
...acc,
|
|
[key]: this.resolveValue(ctx, currentNodeId, val, loopScope, block),
|
|
}),
|
|
{}
|
|
)
|
|
}
|
|
|
|
if (typeof value === 'string') {
|
|
return this.resolveTemplate(ctx, currentNodeId, value, loopScope, block)
|
|
}
|
|
return value
|
|
}
|
|
private resolveTemplate(
|
|
ctx: ExecutionContext,
|
|
currentNodeId: string,
|
|
template: string,
|
|
loopScope?: LoopScope,
|
|
block?: SerializedBlock
|
|
): string {
|
|
const resolutionContext: ResolutionContext = {
|
|
executionContext: ctx,
|
|
executionState: this.state,
|
|
currentNodeId,
|
|
loopScope,
|
|
}
|
|
|
|
let replacementError: Error | null = null
|
|
|
|
const blockType = block?.metadata?.id
|
|
const language =
|
|
blockType === BlockType.FUNCTION
|
|
? ((block?.config?.params as Record<string, unknown> | undefined)?.language as
|
|
| string
|
|
| undefined)
|
|
: undefined
|
|
|
|
let result = replaceValidReferences(template, (match) => {
|
|
if (replacementError) return match
|
|
|
|
try {
|
|
const resolved = this.resolveReference(match, resolutionContext)
|
|
if (resolved === undefined) {
|
|
return match
|
|
}
|
|
|
|
if (resolved === RESOLVED_EMPTY) {
|
|
if (blockType === BlockType.FUNCTION) {
|
|
return this.blockResolver.formatValueForBlock(null, blockType, language)
|
|
}
|
|
return ''
|
|
}
|
|
|
|
return this.blockResolver.formatValueForBlock(resolved, blockType, language)
|
|
} catch (error) {
|
|
replacementError = error instanceof Error ? error : new Error(String(error))
|
|
return match
|
|
}
|
|
})
|
|
|
|
if (replacementError !== null) {
|
|
throw replacementError
|
|
}
|
|
|
|
result = result.replace(createEnvVarPattern(), (match) => {
|
|
const resolved = this.resolveReference(match, resolutionContext)
|
|
return typeof resolved === 'string' ? resolved : match
|
|
})
|
|
return result
|
|
}
|
|
|
|
private resolveTemplateWithoutConditionFormatting(
|
|
ctx: ExecutionContext,
|
|
currentNodeId: string,
|
|
template: string,
|
|
loopScope?: LoopScope
|
|
): string {
|
|
const resolutionContext: ResolutionContext = {
|
|
executionContext: ctx,
|
|
executionState: this.state,
|
|
currentNodeId,
|
|
loopScope,
|
|
}
|
|
|
|
let replacementError: Error | null = null
|
|
|
|
let result = replaceValidReferences(template, (match) => {
|
|
if (replacementError) return match
|
|
|
|
try {
|
|
const resolved = this.resolveReference(match, resolutionContext)
|
|
if (resolved === undefined) {
|
|
return match
|
|
}
|
|
|
|
if (resolved === RESOLVED_EMPTY) {
|
|
return 'null'
|
|
}
|
|
|
|
if (typeof resolved === 'string') {
|
|
const escaped = resolved
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/'/g, "\\'")
|
|
.replace(/\n/g, '\\n')
|
|
.replace(/\r/g, '\\r')
|
|
.replace(/\u2028/g, '\\u2028')
|
|
.replace(/\u2029/g, '\\u2029')
|
|
return `'${escaped}'`
|
|
}
|
|
if (typeof resolved === 'object' && resolved !== null) {
|
|
return JSON.stringify(resolved)
|
|
}
|
|
return String(resolved)
|
|
} catch (error) {
|
|
replacementError = error instanceof Error ? error : new Error(String(error))
|
|
return match
|
|
}
|
|
})
|
|
|
|
if (replacementError !== null) {
|
|
throw replacementError
|
|
}
|
|
|
|
result = result.replace(createEnvVarPattern(), (match) => {
|
|
const resolved = this.resolveReference(match, resolutionContext)
|
|
return typeof resolved === 'string' ? resolved : match
|
|
})
|
|
return result
|
|
}
|
|
|
|
private resolveReference(reference: string, context: ResolutionContext): any {
|
|
for (const resolver of this.resolvers) {
|
|
if (resolver.canResolve(reference)) {
|
|
const result = resolver.resolve(reference, context)
|
|
return result
|
|
}
|
|
}
|
|
|
|
logger.warn('No resolver found for reference', { reference })
|
|
return undefined
|
|
}
|
|
}
|