Files
sim/apps/sim/executor/variables/resolver.ts
Waleed 8f3e864751 fix(security): pentest remediation — condition escaping, SSRF hardening, ReDoS protection (#3820)
* 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>
2026-03-27 19:54:00 -07:00

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
}
}