From 3649f4223d5aeb2ed9c8bf67c734c3e0c5ddf2f0 Mon Sep 17 00:00:00 2001 From: Cameron G Date: Fri, 26 Jun 2026 09:10:59 +0000 Subject: [PATCH 1/5] fix(image_utils): reject path-traversal payloads in document metadata When _extract_image_sources_info is called with a non-empty root_path, the resolved file path must now stay within that root. Document metadata containing '../' sequences or absolute paths that escape the configured root raises ValueError before any filesystem read. This blocks an exfiltration vector where attacker-controlled file_path metadata on indexed documents could cause image-conversion pipelines (LLMDocumentContentExtractor, DocumentToImageContent) to read arbitrary host files and forward their contents to an external LLM endpoint. Behaviour with an empty root_path is unchanged. Tests: - rejects '../../../etc/passwd' relative payload - rejects absolute path outside root - accepts path that resolves inside root --- .../converters/image/image_utils.py | 15 +++++++++ ...path-traversal-guard-951e4271322a3bf5.yaml | 11 +++++++ .../converters/image/test_image_utils.py | 31 +++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 releasenotes/notes/image_utils-path-traversal-guard-951e4271322a3bf5.yaml diff --git a/haystack/components/converters/image/image_utils.py b/haystack/components/converters/image/image_utils.py index 1775ae2aa53..56c21bc87b4 100644 --- a/haystack/components/converters/image/image_utils.py +++ b/haystack/components/converters/image/image_utils.py @@ -248,6 +248,21 @@ def _extract_image_sources_info( ) resolved_file_path = Path(root_path, file_path) + + # Defence-in-depth: when a non-empty root_path is supplied, require the resolved file path + # to stay within that root. This blocks path-traversal payloads (e.g. "../../etc/passwd") in + # attacker-controlled document metadata from reading arbitrary files on the host. + # When root_path is empty/falsy, no containment is enforced (preserves the existing + # behaviour for callers that pass absolute file paths directly). + if root_path: + resolved_file_path = resolved_file_path.resolve() + resolved_root = Path(root_path).resolve() + if not resolved_file_path.is_relative_to(resolved_root): + raise ValueError( + f"Document with ID '{doc.id}' has a file path '{file_path}' that escapes the " + f"configured root '{root_path}'. Resolved path: '{resolved_file_path}'." + ) + if not resolved_file_path.is_file(): raise ValueError( f"Document with ID '{doc.id}' has an invalid file path '{resolved_file_path}'. " diff --git a/releasenotes/notes/image_utils-path-traversal-guard-951e4271322a3bf5.yaml b/releasenotes/notes/image_utils-path-traversal-guard-951e4271322a3bf5.yaml new file mode 100644 index 00000000000..21ae80f3af4 --- /dev/null +++ b/releasenotes/notes/image_utils-path-traversal-guard-951e4271322a3bf5.yaml @@ -0,0 +1,11 @@ +--- +security: + - | + Hardened ``_extract_image_sources_info`` in ``haystack.components.converters.image.image_utils`` + against path-traversal payloads supplied via document metadata. When a non-empty ``root_path`` + is configured, the resolved file path must now stay within that root; ``../`` sequences and + absolute paths that escape the root now raise ``ValueError`` before any filesystem read. This + prevents attacker-controlled ``file_path`` metadata (e.g. on documents indexed from untrusted + sources) from causing image-conversion pipelines to read and exfiltrate arbitrary host files + through ``LLMDocumentContentExtractor`` or ``DocumentToImageContent``. Existing callers that + pass an empty ``root_path`` and supply already-validated absolute paths are unaffected. diff --git a/test/components/converters/image/test_image_utils.py b/test/components/converters/image/test_image_utils.py index 6fb37c1edbc..8d6e3338298 100644 --- a/test/components/converters/image/test_image_utils.py +++ b/test/components/converters/image/test_image_utils.py @@ -149,6 +149,37 @@ def test_extract_image_source_info_errors(self, test_files_path): with pytest.raises(ValueError, match="missing the 'page_number' key"): _extract_image_sources_info(documents=[document], file_path_meta_field="file_path", root_path="") + def test_extract_image_source_info_rejects_path_traversal(self, test_files_path): + # Attacker-controlled document metadata attempts to escape the configured root. + document = Document(content="test", meta={"file_path": "../../../../../../etc/passwd"}) + with pytest.raises(ValueError, match="escapes the configured root"): + _extract_image_sources_info( + documents=[document], + file_path_meta_field="file_path", + root_path=str(test_files_path / "images"), + ) + + def test_extract_image_source_info_rejects_absolute_outside_root(self, test_files_path): + # Absolute path that lies outside the configured root must be rejected before any IO. + document = Document(content="test", meta={"file_path": "/etc/passwd"}) + with pytest.raises(ValueError, match="escapes the configured root"): + _extract_image_sources_info( + documents=[document], + file_path_meta_field="file_path", + root_path=str(test_files_path / "images"), + ) + + def test_extract_image_source_info_accepts_path_inside_root(self, test_files_path): + # When the resolved path is inside the configured root, processing must succeed. + document = Document(content="test", meta={"file_path": "haystack-logo.png"}) + images_source_info = _extract_image_sources_info( + documents=[document], + file_path_meta_field="file_path", + root_path=str(test_files_path / "images"), + ) + assert len(images_source_info) == 1 + assert images_source_info[0]["mime_type"] == "image/png" + class TestBatchConvertPdfPagesToImages: @patch("haystack.components.converters.image.image_utils._convert_pdf_to_images") From e307591f5fa911dd4b5692f6811c25d0cf80e7ff Mon Sep 17 00:00:00 2001 From: Cameron G <156701171+camgrimsec@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:00:13 -0400 Subject: [PATCH 2/5] Update releasenotes/notes/image_utils-path-traversal-guard-951e4271322a3bf5.yaml Co-authored-by: Sebastian Husch Lee <10526848+sjrl@users.noreply.github.com> --- ..._utils-path-traversal-guard-951e4271322a3bf5.yaml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/releasenotes/notes/image_utils-path-traversal-guard-951e4271322a3bf5.yaml b/releasenotes/notes/image_utils-path-traversal-guard-951e4271322a3bf5.yaml index 21ae80f3af4..db829f8fa92 100644 --- a/releasenotes/notes/image_utils-path-traversal-guard-951e4271322a3bf5.yaml +++ b/releasenotes/notes/image_utils-path-traversal-guard-951e4271322a3bf5.yaml @@ -1,11 +1,7 @@ --- security: - | - Hardened ``_extract_image_sources_info`` in ``haystack.components.converters.image.image_utils`` - against path-traversal payloads supplied via document metadata. When a non-empty ``root_path`` - is configured, the resolved file path must now stay within that root; ``../`` sequences and - absolute paths that escape the root now raise ``ValueError`` before any filesystem read. This - prevents attacker-controlled ``file_path`` metadata (e.g. on documents indexed from untrusted - sources) from causing image-conversion pipelines to read and exfiltrate arbitrary host files - through ``LLMDocumentContentExtractor`` or ``DocumentToImageContent``. Existing callers that - pass an empty ``root_path`` and supply already-validated absolute paths are unaffected. + Fixed a path-traversal issue in ``DocumentToImageContent`` and ``LLMDocumentContentExtractor``. + When ``root_path`` is set, a ``file_path`` in document metadata that escapes the root (via + ``../`` or an absolute path) now raises a ``ValueError`` before any file is read, preventing + attacker-controlled metadata from reading arbitrary files on the host. From 0c133da3b38c2a184ea9957370a4e29c81af609d Mon Sep 17 00:00:00 2001 From: Cameron G <156701171+camgrimsec@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:00:35 -0400 Subject: [PATCH 3/5] Update haystack/components/converters/image/image_utils.py Co-authored-by: Sebastian Husch Lee <10526848+sjrl@users.noreply.github.com> --- haystack/components/converters/image/image_utils.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/haystack/components/converters/image/image_utils.py b/haystack/components/converters/image/image_utils.py index 56c21bc87b4..9b2e005188a 100644 --- a/haystack/components/converters/image/image_utils.py +++ b/haystack/components/converters/image/image_utils.py @@ -248,12 +248,9 @@ def _extract_image_sources_info( ) resolved_file_path = Path(root_path, file_path) - - # Defence-in-depth: when a non-empty root_path is supplied, require the resolved file path - # to stay within that root. This blocks path-traversal payloads (e.g. "../../etc/passwd") in - # attacker-controlled document metadata from reading arbitrary files on the host. - # When root_path is empty/falsy, no containment is enforced (preserves the existing - # behaviour for callers that pass absolute file paths directly). + + # When root_path is set, ensure the resolved path stays within it to block path-traversal + # payloads (e.g. "../../etc/passwd") coming from document metadata. if root_path: resolved_file_path = resolved_file_path.resolve() resolved_root = Path(root_path).resolve() From 1d7e451a228ce676edff9e9546da2ee2d9cb1e30 Mon Sep 17 00:00:00 2001 From: Cameron G Date: Tue, 30 Jun 2026 12:11:18 +0000 Subject: [PATCH 4/5] style: strip trailing whitespace flagged by ruff W293 --- haystack/components/converters/image/image_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/haystack/components/converters/image/image_utils.py b/haystack/components/converters/image/image_utils.py index 9b2e005188a..742b08a9ce5 100644 --- a/haystack/components/converters/image/image_utils.py +++ b/haystack/components/converters/image/image_utils.py @@ -248,7 +248,7 @@ def _extract_image_sources_info( ) resolved_file_path = Path(root_path, file_path) - + # When root_path is set, ensure the resolved path stays within it to block path-traversal # payloads (e.g. "../../etc/passwd") coming from document metadata. if root_path: From 15ff4387f36e12a470a5f86e539db9a1eb32dcd5 Mon Sep 17 00:00:00 2001 From: Cameron G Date: Tue, 30 Jun 2026 12:18:59 +0000 Subject: [PATCH 5/5] style: apply ruff format to new tests --- test/components/converters/image/test_image_utils.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/test/components/converters/image/test_image_utils.py b/test/components/converters/image/test_image_utils.py index 8d6e3338298..ac455f16420 100644 --- a/test/components/converters/image/test_image_utils.py +++ b/test/components/converters/image/test_image_utils.py @@ -154,9 +154,7 @@ def test_extract_image_source_info_rejects_path_traversal(self, test_files_path) document = Document(content="test", meta={"file_path": "../../../../../../etc/passwd"}) with pytest.raises(ValueError, match="escapes the configured root"): _extract_image_sources_info( - documents=[document], - file_path_meta_field="file_path", - root_path=str(test_files_path / "images"), + documents=[document], file_path_meta_field="file_path", root_path=str(test_files_path / "images") ) def test_extract_image_source_info_rejects_absolute_outside_root(self, test_files_path): @@ -164,18 +162,14 @@ def test_extract_image_source_info_rejects_absolute_outside_root(self, test_file document = Document(content="test", meta={"file_path": "/etc/passwd"}) with pytest.raises(ValueError, match="escapes the configured root"): _extract_image_sources_info( - documents=[document], - file_path_meta_field="file_path", - root_path=str(test_files_path / "images"), + documents=[document], file_path_meta_field="file_path", root_path=str(test_files_path / "images") ) def test_extract_image_source_info_accepts_path_inside_root(self, test_files_path): # When the resolved path is inside the configured root, processing must succeed. document = Document(content="test", meta={"file_path": "haystack-logo.png"}) images_source_info = _extract_image_sources_info( - documents=[document], - file_path_meta_field="file_path", - root_path=str(test_files_path / "images"), + documents=[document], file_path_meta_field="file_path", root_path=str(test_files_path / "images") ) assert len(images_source_info) == 1 assert images_source_info[0]["mime_type"] == "image/png"