|
2 | 2 | # |
3 | 3 | # SPDX-License-Identifier: Apache-2.0 |
4 | 4 |
|
| 5 | +import functools |
5 | 6 | import io |
6 | 7 | import json |
| 8 | +import operator |
7 | 9 | import subprocess |
8 | 10 | from collections.abc import Callable |
9 | 11 |
|
@@ -302,6 +304,108 @@ def test_unsafe_mode_bypasses_the_block(self): |
302 | 304 | assert deserialize_callable("builtins.eval") is eval |
303 | 305 |
|
304 | 306 |
|
| 307 | +class TestModuleAttributeWalkBypass: |
| 308 | + """ |
| 309 | + The allowlist must be enforced against the module a handle |
| 310 | + *actually resolves to*, not against a string prefix of the declared handle. |
| 311 | +
|
| 312 | + An allowlisted package can expose another module as an attribute (a module-scope `import os` |
| 313 | + makes `haystack.utils.auth.os` the standard-library `os` module). A handle like |
| 314 | + `haystack.utils.auth.os.system` carries the allowlisted `haystack` prefix, so the old |
| 315 | + prefix-only check accepted it, and the resolver then walked `.os.system` into the un-allowlisted |
| 316 | + `os` module — a deserialization-allowlist bypass reaching arbitrary command execution. |
| 317 | + """ |
| 318 | + |
| 319 | + # (handle, module the walk escapes into) — real gadgets present in the default install. |
| 320 | + _GADGETS = [ |
| 321 | + ("haystack.utils.auth.os.system", "os"), |
| 322 | + ("haystack.utils.auth.os.popen", "os"), |
| 323 | + ("haystack.components.converters.output_adapter.ast.literal_eval", "ast"), |
| 324 | + ] |
| 325 | + |
| 326 | + @pytest.mark.parametrize("handle, escaped_module", _GADGETS) |
| 327 | + def test_callable_walk_into_unallowlisted_module_rejected(self, handle, escaped_module): |
| 328 | + with pytest.raises(DeserializationError, match=f"module '{escaped_module}'"): |
| 329 | + deserialize_callable(handle) |
| 330 | + |
| 331 | + def test_class_path_module_leak_rejected(self): |
| 332 | + # The class path resolves `haystack.utils.auth.os` to the `os` module itself; it must not |
| 333 | + # leak an un-allowlisted module as if it were a class. |
| 334 | + with pytest.raises(DeserializationError, match="module 'os'"): |
| 335 | + import_class_by_name("haystack.utils.auth.os") |
| 336 | + |
| 337 | + def test_type_path_module_leak_rejected(self): |
| 338 | + with pytest.raises(DeserializationError, match="module 'os'"): |
| 339 | + deserialize_type("haystack.utils.auth.os") |
| 340 | + |
| 341 | + def test_resolved_module_check_covers_plain_attribute(self, monkeypatch): |
| 342 | + # Defense-in-depth: a dangerous callable bound as a plain (non-module) attribute of an |
| 343 | + # allowlisted module is not caught by the module-walk check but must still be rejected by |
| 344 | + # the resolved-`__module__` gate (`subprocess.getoutput.__module__ == "subprocess"`). |
| 345 | + monkeypatch.setattr("haystack.utils.auth.injected_gadget", subprocess.getoutput, raising=False) |
| 346 | + with pytest.raises(DeserializationError, match="module 'subprocess'"): |
| 347 | + deserialize_callable("haystack.utils.auth.injected_gadget") |
| 348 | + |
| 349 | + def test_legitimate_haystack_callable_still_resolves(self): |
| 350 | + from haystack.utils.callable_serialization import serialize_callable |
| 351 | + |
| 352 | + handle = "haystack.utils.callable_serialization.serialize_callable" |
| 353 | + assert deserialize_callable(handle) is serialize_callable |
| 354 | + |
| 355 | + def test_pipeline_loads_rejects_gadget_yaml(self): |
| 356 | + # End-to-end through the public default-safe API: an attacker-supplied pipeline naming the |
| 357 | + # gadget as an `OutputAdapter` custom filter must be rejected at load time. |
| 358 | + gadget_yaml = ( |
| 359 | + "components:\n" |
| 360 | + " adapter:\n" |
| 361 | + " type: haystack.components.converters.output_adapter.OutputAdapter\n" |
| 362 | + " init_parameters:\n" |
| 363 | + ' template: "{{ x }}"\n' |
| 364 | + " output_type: str\n" |
| 365 | + " custom_filters:\n" |
| 366 | + ' pwn: "haystack.utils.auth.os.system"\n' |
| 367 | + "connections: []\n" |
| 368 | + ) |
| 369 | + with pytest.raises(DeserializationError): |
| 370 | + Pipeline.loads(gadget_yaml) |
| 371 | + |
| 372 | + |
| 373 | +class TestPrivateBackingModuleCompatibility: |
| 374 | + """ |
| 375 | + A symbol legitimately exposed by an allowlisted public module can report a *private* C |
| 376 | + accelerator as its `__module__` (`operator.add.__module__ == "_operator"`, |
| 377 | + `io.StringIO.__module__ == "_io"`). The resolved-module gate must still accept these when the |
| 378 | + private module backs the allowlisted module the object was resolved from — otherwise allowing |
| 379 | + a public module would silently fail to resolve its own members and force users to widen trust |
| 380 | + to low-level private modules. |
| 381 | + """ |
| 382 | + |
| 383 | + def test_callable_from_private_backing_module_resolves(self): |
| 384 | + with _deserialization_context(allowed_modules=["operator"]): |
| 385 | + assert deserialize_callable("operator.add") is operator.add |
| 386 | + |
| 387 | + def test_callable_functools_reduce_resolves(self): |
| 388 | + with _deserialization_context(allowed_modules=["functools"]): |
| 389 | + assert deserialize_callable("functools.reduce") is functools.reduce |
| 390 | + |
| 391 | + def test_type_from_private_backing_module_resolves(self): |
| 392 | + with _deserialization_context(allowed_modules=["io"]): |
| 393 | + assert deserialize_type("io.StringIO") is io.StringIO |
| 394 | + |
| 395 | + def test_import_class_from_private_backing_module_resolves(self): |
| 396 | + with _deserialization_context(allowed_modules=["io"]): |
| 397 | + assert import_class_by_name("io.StringIO") is io.StringIO |
| 398 | + |
| 399 | + def test_exemption_is_scoped_to_the_matching_private_module(self, monkeypatch): |
| 400 | + # The exemption only trusts the private module that backs the *same* allowlisted module. |
| 401 | + # A symbol whose real module is a different (still un-allowlisted) private module must stay |
| 402 | + # blocked: here `operator` is allowed but the injected attribute resolves to `_io`. |
| 403 | + monkeypatch.setattr("operator.injected_gadget", io.StringIO, raising=False) |
| 404 | + with _deserialization_context(allowed_modules=["operator"]): |
| 405 | + with pytest.raises(DeserializationError, match="module '_io'"): |
| 406 | + deserialize_callable("operator.injected_gadget") |
| 407 | + |
| 408 | + |
305 | 409 | @pytest.fixture |
306 | 410 | def _registered_untrusted_component(): |
307 | 411 | """ |
|
0 commit comments