Coverage for haystack/core/serialization_security.py: 100%

135 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 13:53 +0000

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5""" 

6Security primitives for pipeline deserialization. 

7 

8This module provides an allowlist mechanism that gates arbitrary imports. 

9 

10Three ways to extend the allowlist: 

11- Per-call kwarg: `Pipeline.load(..., allowed_modules=["mypkg.*"])` 

12- Process-wide programmatic API: :func:`allow_deserialization_module` 

13- Environment variable: `HAYSTACK_DESERIALIZATION_ALLOWLIST="mypkg.*,otherpkg.*"` 

14 

15The two-mode loading API (`unsafe=True`) bypasses the allowlist entirely. For deployments that only 

16ever load fully trusted pipelines and cannot pass `unsafe=True` at every call site, the process-wide 

17environment variable `HAYSTACK_UNSAFE_DESERIALIZATION=1` is equivalent to `unsafe=True` on every load: 

18it disables *all* deserialization safety checks (the module allowlist, the builtin/import-primitive 

19and control-plane denylists, the object-internals traversal guard, and the refusal to honor a 

20component's own `unsafe: true` flag). Only enable it when every pipeline loaded by the process is 

21trusted. Its value is read once, on the first deserialization in the process, and then frozen for the 

22process lifetime, so nothing that runs later can turn the safety checks off (or back on). 

23""" 

24 

25import builtins 

26import contextvars 

27import fnmatch 

28import importlib 

29import os 

30from collections.abc import Callable, Iterable, Iterator 

31from contextlib import contextmanager 

32from dataclasses import dataclass, field 

33from types import ModuleType 

34from typing import TypeVar 

35 

36from haystack import logging 

37from haystack.core.errors import DeserializationError 

38 

39# The default allowlist covers Haystack's own packages plus a small set of standard-library type modules 

40# that are commonly referenced in serialized type annotations (e.g. `typing.List[str]`, 

41# `collections.deque`). Importing these modules has no meaningful side effects on its own. 

42DEFAULT_ALLOWED_MODULES: tuple[str, ...] = ( 

43 "haystack", 

44 "haystack_integrations", 

45 "haystack_experimental", 

46 "builtins", 

47 "typing", 

48 "collections", 

49) 

50DESERIALIZATION_ALLOWLIST_ENV_VAR = "HAYSTACK_DESERIALIZATION_ALLOWLIST" 

51 

52# Process-wide "off switch": when set to a truthy value, deserialization behaves as if every load 

53# were called with `unsafe=True` (see `_is_unsafe_deserialization`). Unlike the allowlist env var, 

54# this disables *all* safety checks, so it must only be used when every pipeline the process loads 

55# is trusted. 

56UNSAFE_DESERIALIZATION_ENV_VAR = "HAYSTACK_UNSAFE_DESERIALIZATION" 

57 

58# `builtins` is on the default allowlist because deserialization legitimately needs builtin *types* 

59# (e.g. `builtins.str`, used in serialized type annotations and as nested `{"type": ...}` class 

60# references) and harmless builtin callables that Haystack's own serializer emits (e.g. 

61# `serialize_callable(print)` -> `"builtins.print"`). The module-granular allowlist is too coarse 

62# to separate those from dangerous members, so the two builtin-resolving contexts are gated 

63# differently: 

64# - Type / class contexts (`deserialize_type`, `import_class_by_name`) require the resolved 

65# builtin to be a `type` (see :func:`_check_builtin_is_type`). That lets every builtin type 

66# through while rejecting every builtin *function*, with no denylist to maintain. 

67# - The callable context (`deserialize_callable`) genuinely returns functions, so it instead 

68# rejects the dangerous builtin *callables* named below (see :func:`_check_not_denied_builtin`). 

69_DENIED_BUILTIN_NAMES: frozenset[str] = frozenset( 

70 { 

71 "eval", # arbitrary code execution 

72 "exec", # arbitrary code execution 

73 "compile", # arbitrary code compilation 

74 "__import__", # dynamic import of any module (gateway to os/subprocess/...) 

75 "open", # filesystem read/write 

76 "getattr", # attribute-traversal gadget (classic sandbox escape) 

77 "setattr", # arbitrary attribute mutation 

78 "delattr", # arbitrary attribute deletion 

79 "globals", # access to module namespaces 

80 "locals", # access to local namespaces 

81 "vars", # access to object/module namespaces 

82 "breakpoint", # runs the PYTHONBREAKPOINT hook 

83 "__build_class__", # dynamic class creation 

84 "type", # dynamic class creation via type(name, bases, dict) 

85 } 

86) 

87 

88# Resolve names to objects once so callers can match by identity, which also catches aliases that 

89# reach the same builtin via a different import path (e.g. `io.open is builtins.open`). 

90_DENIED_BUILTIN_OBJECTS: frozenset = frozenset([getattr(builtins, name) for name in _DENIED_BUILTIN_NAMES]) 

91 

92# Import primitives are functional twins of the denied builtin `__import__`: they load an arbitrary 

93# module by name (the gateway to `os`/`subprocess`/...). The module-granular allowlist does not stop 

94# them, because a wrapper like `haystack.utils.type_serialization.thread_safe_import` lives inside 

95# the allowlisted `haystack` namespace and reports `__module__ == "haystack..."`, and the builtin 

96# denylist above only matches `builtins` members by identity. Deny these import primitives too. 

97_DENIED_CALLABLE_OBJECTS: frozenset = frozenset({importlib.import_module, importlib.reload}) 

98# `thread_safe_import` is matched by its (`__module__`, `__qualname__`) pair rather than by identity 

99# so this module need not import `haystack.utils.type_serialization` (which itself imports from this 

100# module, so an import here would be circular). 

101_DENIED_CALLABLE_QUALNAMES: frozenset[tuple[str, str]] = frozenset( 

102 {("haystack.utils.type_serialization", "thread_safe_import")} 

103) 

104 

105logger = logging.getLogger(__name__) 

106 

107 

108@dataclass(frozen=True) 

109class _DeserializationContext: 

110 extra_allowed: tuple[str, ...] = field(default_factory=tuple) 

111 unsafe: bool = False 

112 

113 

114_current_context: contextvars.ContextVar[_DeserializationContext | None] = contextvars.ContextVar( 

115 "haystack_deserialization_context", default=None 

116) 

117 

118 

119def _get_context() -> _DeserializationContext: 

120 ctx = _current_context.get() 

121 return ctx if ctx is not None else _DeserializationContext() 

122 

123 

124# Snapshot of `UNSAFE_DESERIALIZATION_ENV_VAR`, read once and then frozen for the process lifetime 

125# (`None` means "not read yet"). Freezing it is a security property, not an optimization: the first 

126# read happens before any deserialized data can run, so a hostile pipeline cannot turn the safety 

127# checks off *while it is being loaded*. Without it, any route from serialized data to `os.environ` 

128# is a full bypass — e.g. an allowlisted module that binds `os.environ` at module scope, whose 

129# `update` resolves because it is `collections.abc.MutableMapping.update` and `collections` is on 

130# the default allowlist. The read is deliberately lazy rather than done at import time, so that a 

131# `load_dotenv()` (or any other env setup) that runs before the first load is still honored. 

132_unsafe_env_snapshot: bool | None = None 

133 

134 

135def _unsafe_env_enabled() -> bool: 

136 """ 

137 Return whether the process-wide unsafe-deserialization env var was set to a truthy value. 

138 

139 :data:`UNSAFE_DESERIALIZATION_ENV_VAR` is read on the first deserialization check in the process 

140 and the result is then frozen for the process lifetime (see `_unsafe_env_snapshot`): later writes 

141 to the variable are ignored, in either direction. A warning is logged when the snapshot is taken 

142 and found active, since it turns off all deserialization safety for the whole process. 

143 """ 

144 global _unsafe_env_snapshot 

145 snapshot = _unsafe_env_snapshot 

146 if snapshot is None: 

147 # A benign race: concurrent first readers compute the same value from the same environment. 

148 snapshot = os.environ.get(UNSAFE_DESERIALIZATION_ENV_VAR, "").strip().lower() in ("1", "true") 

149 _unsafe_env_snapshot = snapshot 

150 if snapshot: 

151 logger.warning( 

152 "{env} is set: pipeline deserialization safety is DISABLED process-wide " 

153 "(equivalent to passing unsafe=True on every load). Only enable this if every " 

154 "pipeline loaded by this process is fully trusted.", 

155 env=UNSAFE_DESERIALIZATION_ENV_VAR, 

156 ) 

157 return snapshot 

158 

159 

160def _is_unsafe_deserialization() -> bool: 

161 """ 

162 Return whether deserialization is running in unsafe mode. 

163 

164 This is the single source of truth consulted by every safety check in this module. It is `True` 

165 when either the active deserialization context was entered with `unsafe=True`, or the process-wide 

166 :data:`UNSAFE_DESERIALIZATION_ENV_VAR` env var was set when it was first read (which disables all 

167 deserialization safety checks for the whole process — see :func:`_unsafe_env_enabled`). 

168 

169 Components deserializing their own data (e.g. `OutputAdapter.from_dict`) use this to decide 

170 whether to honor an embedded `unsafe` flag: a serialized component may only disable its Jinja 

171 sandbox when the whole pipeline is being loaded in unsafe mode (`Pipeline.load(..., unsafe=True)`), 

172 never on its own from otherwise-untrusted data in default safe mode. 

173 """ 

174 # `_unsafe_env_enabled()` first so the env snapshot is always taken on the earliest 

175 # check in the process, even when that check happens inside an `unsafe=True` load. 

176 return _unsafe_env_enabled() or _get_context().unsafe 

177 

178 

179_F = TypeVar("_F", bound=Callable[..., object]) 

180 

181# Attribute stamped on callables that are part of the deserializer's own machinery so that the 

182# resolution paths can refuse to hand them back (see `mark_deserialization_internal`). 

183_DESERIALIZATION_INTERNAL_ATTR = "_haystack_deserialization_internal" 

184 

185 

186def mark_deserialization_internal(func: _F) -> _F: 

187 """ 

188 Mark a callable as deserializer-internal so it can never be produced by deserializing untrusted data. 

189 

190 The allowlist admits the whole `haystack` namespace so Haystack can deserialize its own 

191 components. That also makes the deserializer's *own* interface resolvable from serialized data: 

192 the allowlist-administration function :func:`allow_deserialization_module` and the resolution 

193 helpers (`deserialize_callable`, `deserialize_type`, `import_class_by_name`). 

194 A hostile pipeline can register `allow_deserialization_module` as a Jinja custom filter, call it 

195 with `"*"` to disarm the allowlist process-wide, then use the equally-resolvable `deserialize_callable` 

196 to resolve and invoke `os.system`, for example. 

197 

198 Stamp such callables at definition time with this decorator; the resolution paths 

199 (:func:`deserialize_callable`, `_import_class_by_name`) refuse to return anything carrying the 

200 mark. Bypassed in `unsafe=True` mode, which disables all deserialization safety checks by design. 

201 

202 :param func: 

203 The callable to mark. 

204 :returns: 

205 The same callable, marked. 

206 """ 

207 setattr(func, _DESERIALIZATION_INTERNAL_ATTR, True) 

208 return func 

209 

210 

211def _is_deserialization_internal(resolved: object) -> bool: 

212 """ 

213 Return whether `resolved` belongs to Haystack's deserialization control plane. 

214 

215 An object belongs to it in any of three ways: 

216 

217 - It is stamped with :func:`mark_deserialization_internal`. This covers the resolution helpers 

218 that live in *other* modules (`deserialize_callable`, `deserialize_type`, `import_class_by_name`). 

219 - It is defined in this module (matched by `__module__`): `allow_deserialization_module` and the 

220 private context machinery (`_DeserializationContext`, the `_check_*`/`_get_context` helpers). 

221 - It is a bound method of the module-level *mutable* control-plane state — the allowlist list 

222 `_extra_allowed_modules` and the `_current_context` context variable. These are reachable 

223 through the very attribute walk the resolver performs (e.g. `_extra_allowed_modules.append`, 

224 `_current_context.set`); their own `__module__` is `builtins`/`None`, so they are matched 

225 by the identity of what they are bound to. Left reachable, they let serialized data append 

226 `"*"` to the allowlist or install an `unsafe` context — operating the control from within 

227 the very data it exists to distrust, which persists process-wide and enables a staged RCE on a 

228 later load. 

229 """ 

230 if getattr(resolved, _DESERIALIZATION_INTERNAL_ATTR, False): 

231 return True 

232 if getattr(resolved, "__module__", None) == __name__: 

233 return True 

234 state = (_extra_allowed_modules, _current_context) 

235 bound_to = getattr(resolved, "__self__", None) 

236 return any(resolved is s or bound_to is s for s in state) 

237 

238 

239def _check_not_deserialization_internal(resolved: object, handle: str) -> None: 

240 """ 

241 Reject `resolved` if it is part of the deserialization control plane. 

242 

243 See :func:`_is_deserialization_internal` for what that covers. 

244 Used by the resolution paths (`deserialize_callable`, `_import_class_by_name`) as a companion to 

245 the builtin and import-primitive denylists. It refuses the allowlist-administration function, the 

246 resolution helpers, and the mutable allowlist/context state — all of which live in (or are 

247 reachable through) the allowlisted `haystack` namespace and would otherwise be resolvable from 

248 serialized data. Bypassed in `unsafe=True` mode, which disables all safety checks. 

249 

250 :param resolved: 

251 The object resolved from the serialized handle. 

252 :param handle: 

253 The original serialized handle, used only for the error message. 

254 :raises DeserializationError: 

255 If `resolved` is part of the deserialization control plane. 

256 """ 

257 if _is_unsafe_deserialization(): 

258 return 

259 if _is_deserialization_internal(resolved): 

260 name = getattr(resolved, "__qualname__", None) or getattr(resolved, "__name__", None) or repr(resolved) 

261 raise DeserializationError( 

262 f"Refusing to deserialize '{handle}': it resolves to '{name}', which is part of Haystack's " 

263 f"deserialization control plane (its allowlist administration, mutable allowlist/context state, " 

264 f"or a resolution helper) and must never be produced by deserializing untrusted data — doing so " 

265 f"would let the data operate the deserialization allowlist against itself. If you trust the " 

266 f"source of this data, load it with unsafe=True to bypass deserialization safety checks." 

267 ) 

268 

269 

270# Non-dunder attribute names that still expose an object's internals — the frame/code/closure 

271# accessors on functions, generators, coroutines and async generators. Dunder names (`__globals__`, 

272# `__dict__`, `__class__`, `__builtins__`, `__subclasses__`, ...) are matched separately by the 

273# `__` prefix; these have no such prefix and must be listed explicitly. 

274_UNSAFE_TRAVERSAL_ATTRS: frozenset[str] = frozenset( 

275 { 

276 "gi_frame", 

277 "gi_code", 

278 "gi_yieldfrom", 

279 "cr_frame", 

280 "cr_code", 

281 "cr_await", 

282 "ag_frame", 

283 "ag_code", 

284 "f_globals", 

285 "f_builtins", 

286 "f_locals", 

287 "f_back", 

288 "f_code", 

289 "func_globals", 

290 "func_code", 

291 "func_closure", 

292 "func_dict", 

293 "func_defaults", 

294 } 

295) 

296 

297 

298def _check_traversable_attribute(name: str, handle: str) -> None: 

299 """ 

300 Reject descending into an object-internals attribute while walking a serialized handle. 

301 

302 Serialized callable/class handles reference public dotted import paths (`module.Class.method`); 

303 they never legitimately traverse into an object's internals. Dunder attributes (`__globals__`, 

304 `__dict__`, `__class__`, `__builtins__`, `__subclasses__`, ...) and the frame/code accessors in 

305 :data:`_UNSAFE_TRAVERSAL_ATTRS` are the classic sandbox-escape gadgets — e.g. `<func>.__globals__` 

306 yields the defining module's live namespace, from which the allowlist state can be rewritten or 

307 `__builtins__` (hence `eval`/`exec`) reached, regardless of any per-object identity check. The 

308 module-granular allowlist does not stop this because the traversal stays inside an allowlisted 

309 module. Bypassed in `unsafe=True` mode, which disables all deserialization safety checks by design. 

310 

311 :param name: 

312 The attribute name about to be resolved from the current object in the walk. 

313 :param handle: 

314 The original serialized handle, used only for the error message. 

315 :raises DeserializationError: 

316 If `name` names an object-internals attribute. 

317 """ 

318 if _is_unsafe_deserialization(): 

319 return 

320 if name.startswith("__") or name in _UNSAFE_TRAVERSAL_ATTRS: 

321 raise DeserializationError( 

322 f"Refusing to deserialize '{handle}': it traverses into the internal attribute '{name}', " 

323 f"which can expose object internals (e.g. '__globals__', '__class__', '__builtins__') and is " 

324 f"a known sandbox-escape gadget. If you trust the source of this data, load it with unsafe=True " 

325 f"to bypass deserialization safety checks." 

326 ) 

327 

328 

329# Process-wide patterns set via allow_deserialization_module. 

330_extra_allowed_modules: list[str] = [] 

331 

332 

333@mark_deserialization_internal 

334def allow_deserialization_module(pattern: str) -> None: 

335 """ 

336 Add a module pattern to the process-wide deserialization allowlist. 

337 

338 Once added, classes from modules matching the pattern can be deserialized from YAML / dict 

339 representations until the process exits. 

340 

341 A pattern matches a module name if: 

342 - The pattern contains `*`, `?` or `[` — :mod:`fnmatch` semantics are used. 

343 - Otherwise the pattern is treated as a prefix: a module matches if it equals the pattern or 

344 is a submodule of it (i.e. starts with `pattern + "."`). A trailing `.*` is stripped 

345 before this comparison, so `"mypkg"` and `"mypkg.*"` behave identically. 

346 

347 :param pattern: 

348 The module pattern to allow. 

349 """ 

350 if pattern not in _extra_allowed_modules: 

351 _extra_allowed_modules.append(pattern) 

352 

353 

354def _module_matches(module_name: str, pattern: str) -> bool: 

355 """Return whether `module_name` matches the given allowlist `pattern`.""" 

356 # `pkg.*` (where the part before `.*` has no other wildcards) is treated as a prefix match — 

357 # matches `pkg` and any submodule. This is the most common form, and we want it to match 

358 # the bare top-level package too (which true fnmatch wouldn't, since `pkg.*` requires a 

359 # literal `.` to follow). Patterns like `j*on.*` keep their wildcards and fall through to 

360 # fnmatch so the semantics stay consistent. 

361 if pattern.endswith(".*") and not any(c in pattern[:-2] for c in "*?["): 

362 prefix = pattern[:-2] 

363 return module_name == prefix or module_name.startswith(prefix + ".") 

364 if any(c in pattern for c in "*?["): 

365 return fnmatch.fnmatchcase(module_name, pattern) 

366 return module_name == pattern or module_name.startswith(pattern + ".") 

367 

368 

369def _patterns_from_env() -> list[str]: 

370 raw = os.environ.get(DESERIALIZATION_ALLOWLIST_ENV_VAR, "") 

371 return [p.strip() for p in raw.split(",") if p.strip()] 

372 

373 

374def _is_module_allowed(module_name: str) -> bool: 

375 """Return whether `module_name` is on the active deserialization allowlist.""" 

376 ctx = _get_context() 

377 if _is_unsafe_deserialization(): 

378 return True 

379 patterns: list[str] = [] 

380 patterns.extend(DEFAULT_ALLOWED_MODULES) 

381 patterns.extend(_extra_allowed_modules) 

382 patterns.extend(_patterns_from_env()) 

383 patterns.extend(ctx.extra_allowed) 

384 return any(_module_matches(module_name, p) for p in patterns) 

385 

386 

387def _check_module_allowed(module_name: str) -> None: 

388 """Raise :class:`DeserializationError` if `module_name` is not on the allowlist.""" 

389 if _is_module_allowed(module_name): 

390 return 

391 raise DeserializationError( 

392 f"Refusing to deserialize a class from module '{module_name}': the module is not on the " 

393 f"trusted-module allowlist. If you trust the source of this serialized data, you can either:\n" 

394 f" - extend the allowlist for this call: " 

395 f"Pipeline.load(..., allowed_modules=['{module_name}']),\n" 

396 f" - extend it process-wide via haystack.core.serialization.allow_deserialization_module" 

397 f"('{module_name}') or the {DESERIALIZATION_ALLOWLIST_ENV_VAR} environment variable,\n" 

398 f" - or bypass the allowlist entirely: Pipeline.load(..., unsafe=True)." 

399 ) 

400 

401 

402def _check_resolved_module_allowed(resolved: object, declared_module: str | None = None) -> None: 

403 """ 

404 Gate on the module a resolved object *actually* comes from, not on the declared handle. 

405 

406 The allowlist checks that run earlier in deserialization apply to the declared dotted path, 

407 which an attacker controls. A crafted handle can name an object re-exported as an attribute of 

408 an allowlisted module and thereby escape the allowlist: 

409 

410 - a module: ``haystack.utils.auth.os`` resolves to the standard-library ``os`` module, because 

411 ``haystack.utils.auth`` does ``import os`` at module scope. The handle has the allowlisted 

412 ``haystack`` prefix, but the resolved object belongs to the un-allowlisted ``os`` module. 

413 - a class or function: ``haystack.<mod>.<Name>`` could resolve a class/function imported into 

414 an allowlisted module from an un-allowlisted one (e.g. a re-exported ``subprocess.Popen``). 

415 

416 Checking the resolved object's real module closes both gaps. Objects without a usable 

417 ``__module__`` (e.g. ``functools.partial`` instances) are left to the other gates; they are not 

418 reachable as gadgets through an attribute walk that stays entirely inside allowlisted modules. 

419 

420 :param resolved: 

421 The object resolved from the serialized handle. 

422 :param declared_module: 

423 The allowlisted module the object was actually resolved from (the module that was imported 

424 and walked). Used to accept the private implementation module that backs a public module — 

425 see below. 

426 :raises DeserializationError: 

427 If the resolved object's real module is not on the allowlist. 

428 """ 

429 if _is_unsafe_deserialization(): 

430 return 

431 # Builtins are gated separately and authoritatively — by the identity denylist 

432 # (`_check_not_denied_builtin`) in the callable path and by the type requirement 

433 # (`_check_builtin_is_type`) in the class path. They can also report a private `__module__` 

434 # (e.g. `open.__module__ == "_io"`), so exempt any object that is a genuine builtin (reachable 

435 # under its own name in the `builtins` module) from the module check to avoid shadowing those 

436 # gates. This exemption cannot widen access: a dangerous builtin is still stopped downstream. 

437 name = getattr(resolved, "__name__", None) 

438 if isinstance(name, str) and getattr(builtins, name, None) is resolved: 

439 return 

440 # Modules carry their identity in `__name__`, not `__module__`. 

441 module_name = resolved.__name__ if isinstance(resolved, ModuleType) else getattr(resolved, "__module__", None) 

442 if not (isinstance(module_name, str) and module_name): 

443 return 

444 if _is_module_allowed(module_name): 

445 return 

446 # Private implementation module backing an allowlisted public module: a symbol legitimately 

447 # exposed by an allowlisted module can report a private `__module__` — e.g. `operator.add` 

448 # resolves to `_operator.add`, `io.StringIO` to `_io.StringIO`. Accept it only when the private 

449 # module is the C accelerator of the *same* allowlisted module the object was resolved from 

450 # (`_operator` backs `operator`, `_io` backs `io`). This never admits a public, un-allowlisted 

451 # module such as `os` or `subprocess`, so the escape gadgets stay blocked. 

452 if ( 

453 declared_module is not None 

454 and module_name.lstrip("_") == declared_module 

455 and _is_module_allowed(declared_module) 

456 ): 

457 return 

458 _check_module_allowed(module_name) 

459 

460 

461def _is_denied_builtin(resolved: object) -> bool: 

462 """ 

463 Return whether `resolved` is one of the builtins denied for callable deserialization. 

464 

465 Matches by identity (not membership) so an unhashable resolved object never raises. 

466 """ 

467 return any(resolved is denied for denied in _DENIED_BUILTIN_OBJECTS) 

468 

469 

470def _check_not_denied_builtin(resolved: object, handle: str) -> None: 

471 """ 

472 Reject `resolved` if it is a builtin callable that is unsafe to resolve from serialized data. 

473 

474 Used by the callable-resolution path (`deserialize_callable`). Raises 

475 :class:`DeserializationError` for the primitives in :data:`_DENIED_BUILTIN_NAMES`, which can 

476 execute code, import modules, touch the filesystem, or escape via attribute/namespace access. 

477 The block applies even though `builtins` is on the allowlist, because the allowlist is 

478 module-granular. It is intentionally bypassed in `unsafe=True` mode, which disables all 

479 deserialization safety checks by design. 

480 

481 :param resolved: 

482 The object resolved from the serialized handle. 

483 :param handle: 

484 The original serialized handle, used only for the error message. 

485 """ 

486 if _is_unsafe_deserialization(): 

487 return 

488 if _is_denied_builtin(resolved): 

489 name = getattr(resolved, "__name__", str(resolved)) 

490 raise DeserializationError( 

491 f"Refusing to deserialize '{handle}': it resolves to the builtin '{name}', which is " 

492 f"blocked because it can be used to execute code, import modules, access the " 

493 f"filesystem, or escape via attribute access. If you trust the source of this data, " 

494 f"load it with unsafe=True to bypass deserialization safety checks." 

495 ) 

496 

497 

498def _check_not_denied_callable(resolved: object, handle: str) -> None: 

499 """ 

500 Reject `resolved` if it is an import primitive that is unsafe to resolve from serialized data. 

501 

502 Used by the callable-resolution path (`deserialize_callable`) as a companion to 

503 :func:`_check_not_denied_builtin`. It blocks the non-builtin import primitives in 

504 :data:`_DENIED_CALLABLE_OBJECTS` / :data:`_DENIED_CALLABLE_QUALNAMES` (e.g. 

505 `importlib.import_module`, `haystack.utils.type_serialization.thread_safe_import`), which are 

506 functionally equivalent to the already-denied builtin `__import__` and can load any module as a 

507 gateway to code execution. Bypassed in `unsafe=True` mode, which disables all safety checks. 

508 

509 :param resolved: 

510 The object resolved from the serialized handle. 

511 :param handle: 

512 The original serialized handle, used only for the error message. 

513 """ 

514 if _is_unsafe_deserialization(): 

515 return 

516 ident = (getattr(resolved, "__module__", ""), getattr(resolved, "__qualname__", "")) 

517 if any(resolved is denied for denied in _DENIED_CALLABLE_OBJECTS) or ident in _DENIED_CALLABLE_QUALNAMES: 

518 raise DeserializationError( 

519 f"Refusing to deserialize '{handle}': it resolves to an import primitive that can load " 

520 f"arbitrary modules (equivalent to the blocked builtin '__import__'), which is a gateway " 

521 f"to code execution. If you trust the source of this data, load it with unsafe=True to " 

522 f"bypass deserialization safety checks." 

523 ) 

524 

525 

526def _check_builtin_is_type(resolved: object, handle: str) -> None: 

527 """ 

528 Reject a `builtins` member resolved in a type/class context that is not a `type`. 

529 

530 Used by `deserialize_type` and `import_class_by_name`, which resolve type annotations and class 

531 references — always classes. Requiring the resolved `builtins` member to be a `type` lets every 

532 builtin type through (e.g. `str`, `memoryview`) while rejecting every builtin *function* (e.g. 

533 `eval`, `exec`, `getattr`), with no denylist to maintain. Bypassed in `unsafe=True` mode. 

534 

535 :param resolved: 

536 The object resolved from the serialized handle. 

537 :param handle: 

538 The original serialized handle, used only for the error message. 

539 """ 

540 if _is_unsafe_deserialization(): 

541 return 

542 if not isinstance(resolved, type): 

543 raise DeserializationError( 

544 f"Refusing to deserialize '{handle}': it resolves to a builtin that is not a type and " 

545 f"cannot be used as a type annotation or class reference. If you trust the source of " 

546 f"this data, load it with unsafe=True to bypass deserialization safety checks." 

547 ) 

548 

549 

550@contextmanager 

551def _deserialization_context(allowed_modules: Iterable[str] | None = None, unsafe: bool = False) -> Iterator[None]: 

552 """ 

553 Context manager that activates a per-call deserialization context. 

554 

555 Patterns from `allowed_modules` are appended to the parent context's patterns, and `unsafe` 

556 is OR-ed with the parent's `unsafe` flag — so this never narrows the active permissions. 

557 The previous context is restored on exit. 

558 """ 

559 parent = _get_context() 

560 extra = parent.extra_allowed + (tuple(allowed_modules) if allowed_modules else ()) 

561 merged_unsafe = parent.unsafe or unsafe 

562 token = _current_context.set(_DeserializationContext(extra_allowed=extra, unsafe=merged_unsafe)) 

563 try: 

564 yield 

565 finally: 

566 _current_context.reset(token)