Skip to content

Commit 139af73

Browse files
authored
Fix/tool result store read boundary (#12059)
1 parent 13cab92 commit 139af73

3 files changed

Lines changed: 63 additions & 6 deletions

File tree

haystack/hooks/tool_result_offloading/stores.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,25 @@ def __init__(self, root: str | Path) -> None:
3030
"""
3131
self.root = Path(root)
3232

33+
def _resolve_in_root(self, path_like: str | Path, *, subject: str) -> Path:
34+
"""
35+
Resolve a path-like value and ensure it stays within the configured store root.
36+
37+
Relative values are interpreted relative to `self.root`; absolute values are used as-is.
38+
39+
:param path_like: Relative or absolute path-like value to resolve.
40+
:param subject: Human-readable label used in the error message.
41+
:returns: The resolved absolute path within the store root.
42+
:raises ValueError: If the resolved path escapes the store root.
43+
"""
44+
root = self.root.resolve()
45+
path = Path(path_like)
46+
candidate = path if path.is_absolute() else root / path
47+
resolved = candidate.resolve()
48+
if not resolved.is_relative_to(root):
49+
raise ValueError(f"{subject} '{path_like}' resolves outside the store root '{root}'.")
50+
return resolved
51+
3352
def write(self, *, key: str, content: str) -> str:
3453
"""
3554
Write `content` to `<root>/<key>`, creating parent directories, and return the file path.
@@ -42,10 +61,7 @@ def write(self, *, key: str, content: str) -> str:
4261
:returns: The absolute path the content was written to, as a string, for use with `read`.
4362
:raises ValueError: If `key` resolves to a location outside the store root.
4463
"""
45-
root = self.root.resolve()
46-
path = (root / key).resolve()
47-
if not path.is_relative_to(root):
48-
raise ValueError(f"Result key '{key}' resolves outside the store root '{root}'.")
64+
path = self._resolve_in_root(key, subject="Result key")
4965
path.parent.mkdir(parents=True, exist_ok=True)
5066
path.write_text(content, encoding="utf-8")
5167
return str(path)
@@ -54,10 +70,14 @@ def read(self, reference: str) -> str:
5470
"""
5571
Read back the content previously written to `reference`.
5672
57-
:param reference: A path returned by `write`.
73+
The resolved reference must stay within the store root: callers must treat it as an opaque
74+
store-scoped reference, not as an arbitrary filesystem path.
75+
76+
:param reference: A store reference returned by `write`.
5877
:returns: The stored content.
78+
:raises ValueError: If `reference` resolves to a location outside the store root.
5979
"""
60-
return Path(reference).read_text(encoding="utf-8")
80+
return self._resolve_in_root(reference, subject="Result reference").read_text(encoding="utf-8")
6181

6282
def to_dict(self) -> dict[str, Any]:
6383
"""
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
security:
3+
- |
4+
Harden ``FileSystemToolResultStore.read()`` so it only reads references that
5+
resolve within the configured store root. This closes a boundary gap where
6+
callers could previously pass an arbitrary filesystem path to ``read()``
7+
instead of a store-scoped reference returned by ``write()``.

test/hooks/tool_result_offloading/test_stores.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#
33
# SPDX-License-Identifier: Apache-2.0
44

5+
import sys
56
from pathlib import Path
67

78
import pytest
@@ -42,6 +43,35 @@ def test_read_round_trips_written_content(self, tmp_path):
4243
reference = store.write(key="a.txt", content="round trip")
4344
assert store.read(reference) == "round trip"
4445

46+
def test_read_rejects_parent_traversal_reference(self, tmp_path):
47+
store = FileSystemToolResultStore(root=tmp_path / "root")
48+
outside = tmp_path / "outside.txt"
49+
outside.write_text("secret", encoding="utf-8")
50+
51+
with pytest.raises(ValueError, match="outside the store root"):
52+
store.read("../outside.txt")
53+
54+
def test_read_rejects_absolute_reference_outside_root(self, tmp_path):
55+
store = FileSystemToolResultStore(root=tmp_path / "root")
56+
outside = tmp_path / "outside.txt"
57+
outside.write_text("secret", encoding="utf-8")
58+
59+
with pytest.raises(ValueError, match="outside the store root"):
60+
store.read(str(outside))
61+
62+
@pytest.mark.skipif(sys.platform == "win32", reason="symlinks require elevated privileges on Windows")
63+
def test_read_rejects_symlink_reference_escaping_root(self, tmp_path):
64+
root = tmp_path / "root"
65+
root.mkdir()
66+
outside = tmp_path / "outside.txt"
67+
outside.write_text("secret", encoding="utf-8")
68+
link = root / "link.txt"
69+
link.symlink_to(outside)
70+
store = FileSystemToolResultStore(root=root)
71+
72+
with pytest.raises(ValueError, match="outside the store root"):
73+
store.read(str(link))
74+
4575
def test_to_dict_from_dict_roundtrip(self, tmp_path):
4676
store = FileSystemToolResultStore(root=tmp_path)
4777
restored = FileSystemToolResultStore.from_dict(store.to_dict())

0 commit comments

Comments
 (0)