Skip to content

fix: strengthen deserialization allowlist checks to prevent bypass vulnerabilities - #12028

Merged
bogdankostic merged 3 commits into
mainfrom
fix-deserialization-allowlist
Jul 17, 2026
Merged

fix: strengthen deserialization allowlist checks to prevent bypass vulnerabilities#12028
bogdankostic merged 3 commits into
mainfrom
fix-deserialization-allowlist

Conversation

@bogdankostic

@bogdankostic bogdankostic commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Related Issues

Proposed Changes:

Fixes a deserialization-allowlist bypass that could lead to arbitrary code execution when loading an untrusted pipeline (Pipeline.load / Pipeline.loads / Pipeline.from_dict).

Root cause. The module allowlist was enforced against a string prefix of the attacker-controlled serialized handle, while the callable/class resolvers then walked attributes from the deepest importable module. Because the pattern haystack matches any haystack.* string, a fabricated handle like haystack.utils.auth.os.system passed the allowlist (it has the haystack prefix), and the resolver imported the real haystack.utils.auth module and walked .os.system into the standard-library os module — since auth.py does import os at module scope.
The dangerous-builtin denylist only covers members of builtins, so os.system / os.popen / ast.literal_eval reached this way were not blocked.

Fix. The allowlist is now enforced against the module each object is actually resolved from, not against a string prefix of the handle:

  • haystack/core/serialization_security.py: new shared helper _check_resolved_module_allowed() that gates on the resolved object's real module (__name__ for modules, __module__ for classes/functions). Genuine builtins are exempted (they are gated authoritatively elsewhere and can report a private __module__, e.g. open.__module__ == "_io"). A symbol whose __module__ is the private C accelerator of the same allowlisted module it was resolved from (e.g. operator.add -> _operator, io.StringIO -> _io) is still accepted, so allowlisting a public module keeps working.
  • haystack/utils/callable_serialization.py: deserialize_callable now (1) only imports module candidates that are on the allowlist — checked before import, so no import-time side effects of an untrusted module — (2) re-checks every module hop during the attribute walk (blocks the ... .os.system escape), and (3) gates the final resolved callable's real module.
  • haystack/utils/type_serialization.py: _import_class_by_name re-checks the resolved class/module's real module, closing the re-export bypass and a module-leak where haystack.utils.auth.os returned the os module as if it were a class.

How did you test it?

  • Unit tests in test/core/test_serialization_security.py:
    • TestModuleAttributeWalkBypass — the real gadgets (haystack.utils.auth.os.system, os.popen, output_adapter.ast.literal_eval), the class/type module-leak, a plain-attribute defense-in-depth case, a legit-still-resolves case, and an end-to-end Pipeline.loads rejection of an attacker OutputAdapter custom_filters payload.
    • TestPrivateBackingModuleCompatibilityoperator.add, functools.reduce, and io.StringIO resolve when the public module is allowlisted, and the private-backing exemption is scoped to the matching module (a symbol resolving to a different private module stays blocked).
  • Broad suite (security, serialization, OutputAdapter, tools, hooks, pipeline): 761 passed.
  • Manual verification: reproduced the original bypass and confirmed it is now blocked at both the primitive level and E2E through Pipeline.loads; confirmed private-backing modules resolve.
  • hatch run fmt and hatch run test:types both pass.

Notes for the reviewer

  • The primary, load-bearing fix is the per-module-hop re-check in deserialize_callable plus the resolved-__module__ gate; the other steps are defense-in-depth.

Checklist

  • I have read the contributors guidelines and the code of conduct.
  • I have updated the related issue with new insights and changes.
  • I have added unit tests and updated the docstrings.
  • I've used one of the conventional commit types for my PR title: fix:, feat:, build:, chore:, ci:, docs:, style:, refactor:, perf:, test: and added ! in case the PR includes breaking changes.
  • I have documented my code.
  • I have added a release note file, following the contributors guidelines.
  • I have run pre-commit hooks and fixed any issue.

@bogdankostic
bogdankostic requested a review from a team as a code owner July 16, 2026 12:42
@bogdankostic
bogdankostic requested review from davidsbatista and removed request for a team July 16, 2026 12:43
@vercel

vercel Bot commented Jul 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
haystack-docs Ready Ready Preview, Comment Jul 17, 2026 7:38am

Request Review

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  haystack/core
  serialization_security.py 206
  haystack/utils
  callable_serialization.py 107-109, 130, 148
  type_serialization.py
Project Total  

This report was generated by python-coverage-comment-action

@davidsbatista davidsbatista left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good!

@bogdankostic
bogdankostic merged commit 4327c6d into main Jul 17, 2026
24 checks passed
@bogdankostic
bogdankostic deleted the fix-deserialization-allowlist branch July 17, 2026 08:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants