Skip to content

Commit d6c6229

Browse files
authored
Backport safe lambda callable resolution to beta2 (#6121)
## Summary - Backport #6112 to `release/3.0.0-beta2`. - Validate lambda strings before resolving them with `string_to_callable`. - Evaluate supported lambda expressions without Python builtins. - Add focused tests for safe lambdas and unsafe payloads. ## Cherry-pick - Cherry-picked `ffe85a4ccc9521cae079bac1c33f7e632e6da26e` with `-x`. ## Testing - `pre-commit run --files source/isaaclab/changelog.d/fix-safe-lambda-callables.rst source/isaaclab/isaaclab/utils/string.py source/isaaclab/test/utils/test_string.py` - `PYTHONPATH=/home/zhengyuz/Projects/IsaacLab.wt/beta2-safe-lambda-string-to-callable/source/isaaclab /home/zhengyuz/Projects/IsaacLab.wt/beta2-automate-collision-stack/.venv/bin/python -m pytest source/isaaclab/test/utils/test_string.py source/isaaclab/test/utils/test_dict.py -q` (26 passed) - `/home/zhengyuz/Projects/IsaacLab.wt/beta2-automate-collision-stack/.venv/bin/python tools/changelog/cli.py check release/3.0.0-beta2`
1 parent 1ef11e5 commit d6c6229

3 files changed

Lines changed: 53 additions & 4 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Fixed
2+
^^^^^
3+
4+
* Fixed :func:`isaaclab.utils.string.string_to_callable` to validate lambda
5+
expression strings before evaluating them and to evaluate them without
6+
Python builtins.

source/isaaclab/isaaclab/utils/string.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ def string_to_slice(s: str):
8989
String <-> Callable operations.
9090
"""
9191

92+
_FORBIDDEN_LAMBDA_NODES = (ast.Call, ast.Attribute, ast.NamedExpr)
93+
9294

9395
def is_lambda_expression(name: str) -> bool:
9496
"""Checks if the input string is a lambda expression.
@@ -100,12 +102,22 @@ def is_lambda_expression(name: str) -> bool:
100102
Whether the input string is a lambda expression.
101103
"""
102104
try:
103-
ast.parse(name)
104-
return isinstance(ast.parse(name).body[0], ast.Expr) and isinstance(ast.parse(name).body[0].value, ast.Lambda)
105+
tree = ast.parse(name, mode="eval")
106+
return isinstance(tree.body, ast.Lambda)
105107
except SyntaxError:
106108
return False
107109

108110

111+
def _validate_lambda_expression(name: str) -> None:
112+
"""Validate that a lambda expression string cannot execute arbitrary code."""
113+
tree = ast.parse(name, mode="eval")
114+
for node in ast.walk(tree):
115+
if isinstance(node, _FORBIDDEN_LAMBDA_NODES):
116+
raise ValueError(f"Unsafe lambda expression '{name}': disallowed syntax '{type(node).__name__}'.")
117+
if isinstance(node, ast.Name) and node.id.startswith("__"):
118+
raise ValueError(f"Unsafe lambda expression '{name}': dunder name '{node.id}' is not allowed.")
119+
120+
109121
def callable_to_string(value: Callable, separator: str = ":") -> str:
110122
"""Converts a callable object to a string.
111123
@@ -152,9 +164,13 @@ def string_to_callable(name: str, separator: str = ":") -> Callable:
152164
Returns:
153165
Callable: The function loaded from the module.
154166
"""
167+
name_is_lambda = is_lambda_expression(name)
168+
if name_is_lambda:
169+
_validate_lambda_expression(name)
170+
155171
try:
156-
if is_lambda_expression(name):
157-
callable_object = eval(name)
172+
if name_is_lambda:
173+
callable_object = eval(name, {"__builtins__": {}}, {})
158174
else:
159175
mod_name, attr_name = name.rsplit(separator, 1)
160176
mod = importlib.import_module(mod_name)

source/isaaclab/test/utils/test_string.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#
44
# SPDX-License-Identifier: BSD-3-Clause
55

6+
import math
67
import random
78

89
import pytest
@@ -61,6 +62,32 @@ def test_case_conversion():
6162
assert string_utils.to_camel_case("snake_case_string", to="cC") == "snakeCaseString"
6263

6364

65+
def test_string_to_callable_allows_safe_lambdas():
66+
"""Test that simple lambda expressions and module references resolve to callables."""
67+
assert string_utils.string_to_callable("lambda x: x + 1")(5) == 6
68+
assert string_utils.string_to_callable("lambda x: x**2")(3) == 9
69+
assert string_utils.string_to_callable("lambda x: x[0] if x else 0")([7, 8]) == 7
70+
assert string_utils.string_to_callable("lambda x: x > 0 and x < 10")(5) is True
71+
assert string_utils.string_to_callable("math:sqrt") is math.sqrt
72+
73+
74+
@pytest.mark.parametrize(
75+
"payload",
76+
[
77+
'lambda x: __import__("os").system("id")',
78+
'lambda x: eval("1")',
79+
"lambda x: (lambda: 1)()",
80+
"lambda x: x.__class__",
81+
"lambda x: __builtins__",
82+
"lambda x: (y := 1)",
83+
],
84+
)
85+
def test_string_to_callable_blocks_unsafe_lambdas(payload):
86+
"""Test that lambda strings cannot execute code or traverse Python internals."""
87+
with pytest.raises(ValueError, match="Unsafe lambda expression"):
88+
string_utils.string_to_callable(payload)
89+
90+
6491
def test_resolve_matching_names_with_basic_strings():
6592
"""Test resolving matching names with a basic expression."""
6693
# list of strings

0 commit comments

Comments
 (0)