mirror of
https://github.com/n8n-io/n8n.git
synced 2026-11-04 23:35:45 +08:00
+7








71262655e4
Co-authored-by: Matsu <matias.huhta@n8n.io> Co-authored-by: Dawid Myslak <dawid.myslak@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Arvin A <51036481+DeveloperTheExplorer@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Dimitri Lavrenük <20122620+dlavrenuek@users.noreply.github.com> Co-authored-by: Garrit Franke <32395585+garritfra@users.noreply.github.com> Co-authored-by: Jaakko Husso <jaakko@n8n.io> Co-authored-by: Daria <daria.staferova@n8n.io> Co-authored-by: Oğuz Eroğlu <oguz.eroglu@n8n.io> Co-authored-by: Robin Braumann <robin.braumann@n8n.io> Co-authored-by: oleg <me@olegivaniv.com> Co-authored-by: Riqwan Thamir <rmthamir@gmail.com> Co-authored-by: José Braulio González Valido <jose.gonzalez@n8n.io> Co-authored-by: Alexander Gekov <40495748+alexander-gekov@users.noreply.github.com> Co-authored-by: Bernhard Wittmann <bernhard.wittmann@n8n.io>
423 lines
16 KiB
Python
423 lines
16 KiB
Python
"""Sandbox hardening regression tests."""
|
|
|
|
import importlib
|
|
|
|
import pytest
|
|
|
|
from src.task_executor import (
|
|
TaskExecutor,
|
|
_PRISTINE_DUNDER_IMPORT,
|
|
_PRISTINE_IMPORT_MODULE,
|
|
)
|
|
from src.task_analyzer import TaskAnalyzer
|
|
from src.errors import SecurityViolationError
|
|
from src.config.security_config import SecurityConfig
|
|
from src.constants import (
|
|
EXECUTOR_SAFE_FORMAT_KEY,
|
|
EXECUTOR_USER_OUTPUT_KEY,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _restore_importlib_entries():
|
|
# ``_harden_importlib`` mutates the global ``importlib`` module. Pull the
|
|
# pristine refs from ``task_executor`` (captured at its module load) so
|
|
# restoration is correct even if a prior test left a guarded version
|
|
# installed.
|
|
try:
|
|
yield
|
|
finally:
|
|
importlib.import_module = _PRISTINE_IMPORT_MODULE
|
|
importlib.__import__ = _PRISTINE_DUNDER_IMPORT
|
|
|
|
|
|
def _realistic_config(stdlib: set[str] | None = None) -> SecurityConfig:
|
|
return SecurityConfig(
|
|
stdlib_allow=stdlib
|
|
if stdlib is not None
|
|
else {"string", "collections", "operator", "importlib"},
|
|
external_allow=set(),
|
|
builtins_deny=set(),
|
|
runner_env_deny=True,
|
|
)
|
|
|
|
|
|
def _run(code: str, config: SecurityConfig | None = None) -> object:
|
|
"""Run user code through the full pipeline. Raises SecurityViolationError
|
|
on any rejection. Returns the user output value on success."""
|
|
config = config or _realistic_config()
|
|
|
|
TaskAnalyzer(config).validate(code)
|
|
compiled = TaskExecutor._compile_user_code(code, "<test>")
|
|
|
|
from src.task_executor import _safe_format
|
|
|
|
TaskExecutor._harden_importlib(config)
|
|
|
|
globs: dict = {
|
|
"__builtins__": TaskExecutor._filter_builtins(config),
|
|
"_items": [{"json": {}}],
|
|
"_query": None,
|
|
"print": TaskExecutor._create_custom_print([]),
|
|
EXECUTOR_SAFE_FORMAT_KEY: _safe_format,
|
|
}
|
|
exec(compiled, globs)
|
|
return globs.get(EXECUTOR_USER_OUTPUT_KEY)
|
|
|
|
|
|
class TestInjectedCallables:
|
|
def test_print_callable_still_records_calls(self):
|
|
print_args: list = []
|
|
callable_obj = TaskExecutor._create_custom_print(print_args)
|
|
callable_obj("hello", 42)
|
|
assert len(print_args) == 1
|
|
assert print_args[0] == ["'hello'", "42"]
|
|
|
|
def test_import_callable_still_enforces_allowlist(self):
|
|
config = SecurityConfig(
|
|
stdlib_allow={"json"},
|
|
external_allow=set(),
|
|
builtins_deny=set(),
|
|
runner_env_deny=True,
|
|
)
|
|
safe_import = TaskExecutor._create_safe_import(config)
|
|
json_mod = safe_import("json", None, None, ("dumps",), 0)
|
|
assert json_mod is not None
|
|
with pytest.raises(SecurityViolationError):
|
|
safe_import("os", None, None, None, 0)
|
|
|
|
def test_print_callable_setattr_is_blocked(self):
|
|
# User code must not be able to swap out the recording list or the
|
|
# formatting helper held in the callable's slots.
|
|
callable_obj = TaskExecutor._create_custom_print([])
|
|
with pytest.raises(AttributeError):
|
|
callable_obj._print_args = ["pwned"]
|
|
with pytest.raises(AttributeError):
|
|
callable_obj._format_print_args = lambda *a: a
|
|
|
|
def test_import_callable_setattr_is_blocked(self):
|
|
callable_obj = TaskExecutor._create_safe_import(_realistic_config())
|
|
with pytest.raises(AttributeError):
|
|
callable_obj._security_config = None
|
|
with pytest.raises(AttributeError):
|
|
callable_obj._original = lambda *a, **k: "pwned"
|
|
|
|
|
|
class TestStringConstantAnalysis:
|
|
def test_bare_blocked_attribute_name_constant_is_rejected(self):
|
|
code = 'return ["__globals__"]'
|
|
with pytest.raises(SecurityViolationError) as exc_info:
|
|
TaskAnalyzer(_realistic_config()).validate(code)
|
|
assert "__globals__" in exc_info.value.description
|
|
|
|
def test_bare_blocked_name_constant_is_rejected(self):
|
|
code = 'return ["__builtins__"]'
|
|
with pytest.raises(SecurityViolationError) as exc_info:
|
|
TaskAnalyzer(_realistic_config()).validate(code)
|
|
assert "__builtins__" in exc_info.value.description
|
|
|
|
def test_safe_format_internal_key_constant_is_rejected(self):
|
|
code = f'return ["{EXECUTOR_SAFE_FORMAT_KEY}"]'
|
|
with pytest.raises(SecurityViolationError):
|
|
TaskAnalyzer(_realistic_config()).validate(code)
|
|
|
|
def test_runtime_concatenated_blocked_name_is_rejected(self):
|
|
code = "attr = '__glob' + 'als__'\nreturn attr"
|
|
with pytest.raises(SecurityViolationError) as exc_info:
|
|
TaskAnalyzer(_realistic_config()).validate(code)
|
|
assert "__globals__" in exc_info.value.description
|
|
|
|
def test_unrelated_strings_pass(self):
|
|
code = "return 'hello world ' + str(1 + 2)"
|
|
TaskAnalyzer(_realistic_config()).validate(code)
|
|
|
|
|
|
class TestFormatterMethodCoverage:
|
|
@pytest.mark.parametrize(
|
|
"method",
|
|
["vformat", "get_field"],
|
|
)
|
|
def test_bare_formatter_method_extraction_is_rejected(self, method):
|
|
code = f"x = ''\nf = x.{method}\nreturn f"
|
|
with pytest.raises(SecurityViolationError):
|
|
TaskAnalyzer(_realistic_config()).validate(code)
|
|
|
|
def test_formatter_vformat_call_with_blocked_template_is_rejected(self):
|
|
code = (
|
|
"import string\n"
|
|
"return string.Formatter().vformat('{0.__globals__}', (print,), {})"
|
|
)
|
|
with pytest.raises(SecurityViolationError):
|
|
_run(code)
|
|
|
|
def test_formatter_get_field_call_with_blocked_field_expression_is_rejected(self):
|
|
code = (
|
|
"import string\n"
|
|
"g, _ = string.Formatter().get_field('0.__globals__', (print,), {})\n"
|
|
"return list(g.keys())"
|
|
)
|
|
with pytest.raises(SecurityViolationError):
|
|
_run(code)
|
|
|
|
|
|
class TestSafeFormatReceiverCoverage:
|
|
def test_userstring_format_template_is_validated(self):
|
|
code = (
|
|
"import collections\n"
|
|
"us = collections.UserString('{0.__globals__}')\n"
|
|
"return us.format(print)"
|
|
)
|
|
with pytest.raises(SecurityViolationError):
|
|
_run(code)
|
|
|
|
|
|
class TestImportAllowlistCoverage:
|
|
def test_importlib_import_module_rejects_disallowed_stdlib(self):
|
|
config = SecurityConfig(
|
|
stdlib_allow={"importlib"},
|
|
external_allow=set(),
|
|
builtins_deny=set(),
|
|
runner_env_deny=True,
|
|
)
|
|
code = "import importlib\nreturn importlib.import_module('os')"
|
|
with pytest.raises(SecurityViolationError):
|
|
_run(code, config)
|
|
|
|
def test_importlib_dunder_import_rejects_disallowed_stdlib(self):
|
|
config = SecurityConfig(
|
|
stdlib_allow={"importlib"},
|
|
external_allow=set(),
|
|
builtins_deny=set(),
|
|
runner_env_deny=True,
|
|
)
|
|
code = "import importlib\nreturn importlib.__import__('os')"
|
|
with pytest.raises(SecurityViolationError):
|
|
_run(code, config)
|
|
|
|
def test_importlib_can_still_import_allowed_module(self):
|
|
config = SecurityConfig(
|
|
stdlib_allow={"importlib", "json"},
|
|
external_allow=set(),
|
|
builtins_deny=set(),
|
|
runner_env_deny=True,
|
|
)
|
|
code = (
|
|
"import importlib\nreturn importlib.import_module('json').dumps({'k': 1})"
|
|
)
|
|
result = _run(code, config)
|
|
assert result == '{"k": 1}'
|
|
|
|
|
|
class TestIntrospectionDeniedOnHardenedCallables:
|
|
"""Regression tests covering the indirect-attribute-access surface.
|
|
|
|
Removing ``__globals__`` from the callable instance is not enough on its
|
|
own: a Python-defined ``__call__`` method has its own ``__globals__``
|
|
pointing back at the defining module. The hardened callable boundary
|
|
must deny attribute access for any name that could expose that path.
|
|
"""
|
|
|
|
def test_print_call_globals_path_via_attrgetter_is_blocked(self):
|
|
# The literal attribute names are assembled at runtime so the static
|
|
# analyzer cannot reject them; this exercises the hardened-callable
|
|
# runtime denial specifically.
|
|
config = SecurityConfig(
|
|
stdlib_allow={"operator"},
|
|
external_allow=set(),
|
|
builtins_deny=set(),
|
|
runner_env_deny=True,
|
|
)
|
|
code = (
|
|
"import operator\n"
|
|
"u = '_' * 2\n"
|
|
"path = '.'.join([u + 'call' + u, u + 'globals' + u])\n"
|
|
"return operator.attrgetter(path)(print)"
|
|
)
|
|
with pytest.raises(AttributeError):
|
|
_run(code, config)
|
|
|
|
def test_safe_import_call_globals_path_via_attrgetter_is_blocked(self):
|
|
config = SecurityConfig(
|
|
stdlib_allow={"operator"},
|
|
external_allow=set(),
|
|
builtins_deny=set(),
|
|
runner_env_deny=True,
|
|
)
|
|
code = (
|
|
"import operator\n"
|
|
"u = '_' * 2\n"
|
|
"path = '.'.join([u + 'call' + u, u + 'globals' + u])\n"
|
|
"return operator.attrgetter(path)(__import__)"
|
|
)
|
|
with pytest.raises(AttributeError):
|
|
_run(code, config)
|
|
|
|
def test_importlib_guarded_call_globals_path_via_attrgetter_is_blocked(self):
|
|
config = SecurityConfig(
|
|
stdlib_allow={"importlib", "operator"},
|
|
external_allow=set(),
|
|
builtins_deny=set(),
|
|
runner_env_deny=True,
|
|
)
|
|
code = (
|
|
"import importlib\n"
|
|
"import operator\n"
|
|
"u = '_' * 2\n"
|
|
"path = '.'.join([u + 'call' + u, u + 'globals' + u])\n"
|
|
"return operator.attrgetter(path)(importlib.import_module)"
|
|
)
|
|
with pytest.raises(AttributeError):
|
|
_run(code, config)
|
|
|
|
def test_missing_attribute_lookup_does_not_reveal_class_name(self):
|
|
# The denied-attribute and missing-attribute paths should produce
|
|
# the same error shape so the message can't be used as an oracle
|
|
# and so the underlying class identity is not exposed.
|
|
callable_obj = TaskExecutor._create_custom_print([])
|
|
with pytest.raises(AttributeError) as exc_info:
|
|
getattr(callable_obj, "definitely_not_an_attribute")
|
|
message = str(exc_info.value)
|
|
for marker in ("_SafePrint", "_HardenedCallable", "_GuardedImport"):
|
|
assert marker not in message, (
|
|
f"AttributeError message must not leak class identity '{marker}': {message!r}"
|
|
)
|
|
|
|
|
|
class TestBuiltinsSanitization:
|
|
"""Reaching a hardened callable's module namespace (e.g. through a future
|
|
gap in the attribute boundary) must not hand back directly usable
|
|
primitives: the leaf module narrows its own builtins to a benign set."""
|
|
|
|
def _leaf_builtins(self) -> dict:
|
|
callable_obj = TaskExecutor._create_custom_print([])
|
|
# ``type(obj)`` sidesteps the instance ``__getattribute__``; class-level
|
|
# access to ``__call__.__globals__`` is the worst-case reachable namespace.
|
|
ns = type(callable_obj).__call__.__globals__["__builtins__"]
|
|
return ns if isinstance(ns, dict) else vars(ns)
|
|
|
|
@pytest.mark.parametrize(
|
|
"name",
|
|
["eval", "exec", "compile", "open", "__import__", "getattr", "globals", "vars"],
|
|
)
|
|
def test_high_leverage_builtin_is_absent(self, name):
|
|
assert name not in self._leaf_builtins()
|
|
|
|
def test_structural_builtins_remain_for_the_boundary(self):
|
|
# Documented residual: object/type stay because the boundary needs them;
|
|
# weaponising them requires attributes the analyzer rejects.
|
|
leaf = self._leaf_builtins()
|
|
assert "object" in leaf and "type" in leaf
|
|
|
|
def test_module_namespace_excludes_executor_internals(self):
|
|
module_ns = type(TaskExecutor._create_custom_print([])).__call__.__globals__
|
|
for leaked in ("os", "sys", "TaskExecutor", "SecurityConfig"):
|
|
assert leaked not in module_ns
|
|
|
|
|
|
class TestSafeFormatBoundary:
|
|
"""The format-validation helper injected into user globals is itself a
|
|
hardened callable, so its executor-bearing globals are not reachable while
|
|
it still validates and dispatches."""
|
|
|
|
def test_safe_format_denies_introspection(self):
|
|
from src.task_executor import _safe_format
|
|
|
|
for attr in ("__globals__", "__call__", "_impl", "__class__"):
|
|
with pytest.raises(AttributeError):
|
|
getattr(_safe_format, attr)
|
|
|
|
def test_safe_format_still_rejects_blocked_template(self):
|
|
from src.task_executor import _safe_format
|
|
|
|
with pytest.raises(SecurityViolationError):
|
|
_safe_format("format", "{0.__globals__}", object())
|
|
|
|
def test_safe_format_dispatches_benign_template(self):
|
|
from src.task_executor import _safe_format
|
|
|
|
assert _safe_format("format", "hi {0}", "there") == "hi there"
|
|
|
|
|
|
class TestUnboundFormatterCalls:
|
|
"""The runtime guard must validate unbound formatter calls the same way
|
|
as bound ones, accounting for the shifted ``self`` argument."""
|
|
|
|
def test_unbound_formatter_get_field_validates_field_argument(self):
|
|
code = (
|
|
"import string\n"
|
|
"formatter = string.Formatter()\n"
|
|
"field = '.'.join(['0', '__globals__'])\n"
|
|
"return string.Formatter.get_field(formatter, field, (print,), {})"
|
|
)
|
|
with pytest.raises(SecurityViolationError):
|
|
_run(code)
|
|
|
|
def test_unbound_formatter_format_validates_template_argument(self):
|
|
code = (
|
|
"import string\n"
|
|
"formatter = string.Formatter()\n"
|
|
"template = '{0.' + '__globals__' + '}'\n"
|
|
"return string.Formatter.format(formatter, template, print)"
|
|
)
|
|
# Either the analyzer catches the assembled template or _safe_format
|
|
# rejects the validated argument at runtime.
|
|
with pytest.raises(SecurityViolationError):
|
|
_run(code)
|
|
|
|
|
|
class TestSafeFormatDoesNotProbeDataAttribute:
|
|
"""`_safe_format` must not evaluate ``.data`` on every non-str receiver
|
|
just to check for ``UserString``-style wrappers; only the concrete type
|
|
triggers that path."""
|
|
|
|
def test_user_class_with_data_property_is_not_triggered(self):
|
|
code = (
|
|
"class C:\n"
|
|
" @property\n"
|
|
" def data(self):\n"
|
|
" raise RuntimeError('unexpected')\n"
|
|
" def format(self):\n"
|
|
" return 'ok'\n"
|
|
"return C().format()"
|
|
)
|
|
# No allowlist needed — nothing is imported.
|
|
config = SecurityConfig(
|
|
stdlib_allow=set(),
|
|
external_allow=set(),
|
|
builtins_deny=set(),
|
|
runner_env_deny=True,
|
|
)
|
|
assert _run(code, config) == "ok"
|
|
|
|
|
|
class TestAssembledStringDoesNotFalsePositive:
|
|
"""The analyzer's compile-time-assembly check is exact-match plus
|
|
format-template scan, not substring matching. User strings that happen
|
|
to contain blocked tokens as substrings must pass."""
|
|
|
|
def test_fstring_containing_objects_word_passes(self):
|
|
code = "n = 3\nreturn f'Found {n} objects'"
|
|
TaskAnalyzer(_realistic_config()).validate(code)
|
|
|
|
def test_concatenation_containing_object_word_passes(self):
|
|
code = "return 'user_' + 'objects'"
|
|
TaskAnalyzer(_realistic_config()).validate(code)
|
|
|
|
def test_module_word_in_string_passes(self):
|
|
code = "return 'load module name'"
|
|
TaskAnalyzer(_realistic_config()).validate(code)
|
|
|
|
def test_fstring_interpolation_does_not_fabricate_blocked_name(self):
|
|
# The literal parts surround an interpolation, so the runtime string
|
|
# can never be a bare ``__globals__``; the analyzer must not assemble
|
|
# the literal fragments into one and reject it.
|
|
code = "x = 5\nreturn f'__glob{x}als__'"
|
|
TaskAnalyzer(_realistic_config()).validate(code)
|
|
|
|
def test_bare_parse_attribute_extraction_is_allowed(self):
|
|
# ``.parse`` is a common method name on unrelated objects (feedparser,
|
|
# dateutil, XML/JSON parsers) and does not evaluate field expressions,
|
|
# so extracting or calling it must not be treated as format access.
|
|
code = "x = ''\nf = x.parse\nreturn f"
|
|
TaskAnalyzer(_realistic_config()).validate(code)
|