Skip to content

feat: add HAYSTACK_UNSAFE_DESERIALIZATION env var - #12397

Merged
julian-risch merged 3 commits into
mainfrom
unsafe-deserialization-env-var
Aug 21, 2026
Merged

feat: add HAYSTACK_UNSAFE_DESERIALIZATION env var#12397
julian-risch merged 3 commits into
mainfrom
unsafe-deserialization-env-var

Conversation

@bogdankostic

Copy link
Copy Markdown
Contributor

Related Issues

  • n/a

Proposed Changes:

Adds a process-wide HAYSTACK_UNSAFE_DESERIALIZATION environment variable that, when set to a truthy value (1 or true), makes every Pipeline.load / Pipeline.loads / Pipeline.from_dict behave as if it were called with unsafe=True — i.e. it disables all deserialization safety checks: the module allowlist, the builtin/import-primitive and control-plane denylists, the object-internals traversal guard, and the refusal to honor a component's own unsafe: true flag.

It's intended for deployments that only ever load fully trusted pipelines and cannot thread unsafe=True through every call site.

Implementation-wise, _is_unsafe_deserialization() becomes the single source of truth for "are we in unsafe mode" and OR-s in the env var there, so every existing safety check inherits it with no other behavior change; the value is read fresh on each call, and a warning is logged once the first time it takes effect.

How did you test it?

  • Added TestUnsafeDeserializationEnvVar in test/core/test_serialization_security.py covering: truthy values (1, true, TRUE, True) bypass the module allowlist and the denied-builtins / import-primitive / control-plane / object-internals-traversal checks; falsey values (0, false, no, off, yes, on, empty) keep safe mode; a serialized OutputAdapter with unsafe: true is refused in safe mode but loads with the env var set; and the "safety disabled" warning is logged only once.

Notes for the reviewer

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.

@vercel

vercel Bot commented Aug 18, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
haystack-docs Ignored Ignored Preview Aug 19, 2026 8:53am

Request Review

@github-actions

github-actions Bot commented Aug 18, 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
Project Total  

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

@bogdankostic
bogdankostic marked this pull request as ready for review August 18, 2026 15:20
@bogdankostic
bogdankostic requested a review from a team as a code owner August 18, 2026 15:20
@bogdankostic
bogdankostic requested review from julian-risch and removed request for a team August 18, 2026 15:20
@sjrl sjrl added this to the 3.1 milestone Aug 19, 2026
Comment on lines 379 to 387
raise DeserializationError(
f"Refusing to deserialize a class from module '{module_name}': the module is not on the "
f"trusted-module allowlist. If you trust the source of this serialized data, you can either:\n"
f" - extend the allowlist for this call: "
f"Pipeline.load(..., allowed_modules=['{module_name}']),\n"
f" - extend it process-wide via haystack.core.serialization.allow_deserialization_module"
f"('{module_name}') or the {DESERIALIZATION_ALLOWLIST_ENV_VAR} environment variable,\n"
f" - or bypass the allowlist entirely: Pipeline.load(..., unsafe=True)."
)

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.

Should we update this message to include the other workaround?

julian-risch and others added 2 commits August 19, 2026 10:31
The release note framed the switch as affecting Pipeline.load / loads /
from_dict, but it is consulted by every deserialization path in the
process, including ones with no unsafe argument of their own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reading HAYSTACK_UNSAFE_DESERIALIZATION fresh on every check made
os.environ part of the deserialization control plane. os.environ.update
is collections.abc.MutableMapping.update, and `collections` is on the
default allowlist, so any allowlisted module that binds os.environ at
module scope turns into a full bypass: a serialized handle resolves the
mutator in safe mode, a Jinja custom_filters call sets the variable
while the component is being constructed, and the rest of that same load
runs with every check disabled.

Read the variable once instead, on the first deserialization check in
the process, and freeze the result. The first check necessarily happens
while resolving the first handle of a load, before any deserialized data
can run, so a hostile pipeline can no longer flip the switch mid-load or
stage it for a later one. The read stays lazy rather than moving to
import time so that a load_dotenv() before the first load still counts.

This also drops the module-level _UNSAFE_ENV_TRUTHY set: a mutable
container in this module is the same gadget class (a resolvable .add
that could make the empty string truthy), and inlining the values
removes the target entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@julian-risch julian-risch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good to me now after I made the environment variable read-once only now. Without that, there was still a gap where an attacker could potentially change the value and thereby enable unsafe deserialization even if the env variable disabled it.

@sjrl sjrl 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!

@julian-risch julian-risch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I found an issue with this and discuss with Sebastian how to fix it. Until then, I need to block merging.

@julian-risch
julian-risch merged commit 12e23f5 into main Aug 21, 2026
24 checks passed
@julian-risch
julian-risch deleted the unsafe-deserialization-env-var branch August 21, 2026 13:28
davidsbatista pushed a commit that referenced this pull request Aug 21, 2026
Co-authored-by: Julian Risch <julian.risch@deepset.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

3 participants