From e006ddbddd209d247ebf1ca81dab25a9d1e7ab67 Mon Sep 17 00:00:00 2001 From: Sydney Lister Date: Wed, 9 Sep 2026 10:26:12 -0400 Subject: [PATCH] Harden Prompty local image paths Reject absolute, traversal, and symlink-escape image paths before reading local files, while preserving in-directory image support and graceful Markdown fallback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6fcc3b99-8914-4e38-9905-16829c362bd7 --- .../azure-ai-evaluation/CHANGELOG.md | 2 + .../ai/evaluation/_legacy/prompty/_utils.py | 54 ++++++------ .../unittests/test_inline_image_fallback.py | 86 +++++++++++++++++++ 3 files changed, 114 insertions(+), 28 deletions(-) diff --git a/sdk/evaluation/azure-ai-evaluation/CHANGELOG.md b/sdk/evaluation/azure-ai-evaluation/CHANGELOG.md index 2be0093f9027..3ab8df30da0e 100644 --- a/sdk/evaluation/azure-ai-evaluation/CHANGELOG.md +++ b/sdk/evaluation/azure-ai-evaluation/CHANGELOG.md @@ -8,6 +8,8 @@ ### Bugs Fixed +- Hardened local Prompty image resolution so only relative files within the Prompty directory are inlined. + ### Other Changes ## 1.18.5 (2026-09-02) diff --git a/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/_legacy/prompty/_utils.py b/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/_legacy/prompty/_utils.py index b13201165085..959448a22754 100644 --- a/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/_legacy/prompty/_utils.py +++ b/sdk/evaluation/azure-ai-evaluation/azure/ai/evaluation/_legacy/prompty/_utils.py @@ -12,7 +12,7 @@ import base64 from dataclasses import dataclass, is_dataclass, fields from logging import Logger -from pathlib import Path +from pathlib import Path, PureWindowsPath from typing import ( Any, AsyncGenerator, @@ -371,16 +371,27 @@ def _inline_image(image: str, working_dir: Path, image_detail: str) -> Dict[str, :rtype: Mapping[str, Any]""" def local_to_base64(local_file: str, mime_type: Optional[str]) -> str: - path = Path(local_file) - if not path.is_absolute(): - path = working_dir / local_file - if not path.exists(): - # TODO ralphe logging? - # logger.warning(f"Cannot find the image path {image_content}, - # it will be regarded as {type(image_str)}.") - raise InvalidInputError(f"Cannot find the image path '{path.as_posix()}'") - - base64_encoded = base64.b64encode(path.read_bytes()).decode("utf-8") + try: + normalized_path = Path(local_file.replace("\\", os.sep).replace("/", os.sep)) + if normalized_path.is_absolute() or PureWindowsPath(local_file).drive: + raise InvalidInputError("Absolute local image paths are not allowed.") + + working_dir_resolved = working_dir.resolve() + path = (working_dir_resolved / normalized_path).resolve() + try: + path.relative_to(working_dir_resolved) + except ValueError as ex: + raise InvalidInputError("Local image paths must resolve within the Prompty directory.") from ex + + if not path.is_file(): + raise InvalidInputError(f"Cannot find the image path '{path.as_posix()}'") + file_contents = path.read_bytes() + except InvalidInputError: + raise + except (OSError, RuntimeError, ValueError) as ex: + raise InvalidInputError(f"Cannot read the local image path '{local_file}'.") from ex + + base64_encoded = base64.b64encode(file_contents).decode("utf-8") if not mime_type: mime_type = FILE_EXT_TO_MIME.get(path.suffix.lower(), DEFAULT_IMAGE_MIME_TYPE) return f"data:{mime_type};base64,{base64_encoded}" @@ -421,29 +432,16 @@ def local_to_base64(local_file: str, mime_type: Optional[str]) -> str: # assume it's a file path local_file = (match.group("link") or "").strip() try: - path = Path(local_file) - if not path.is_absolute(): - path = working_dir / local_file - if not path.exists(): - # The link could not be resolved to an existing local file. This can happen when markdown - # image syntax (e.g. ![alt](figures/1.1)) originates from Document Intelligence or similar - # services where the paths are relative references that are not actual files on disk. - # Treat the original markdown as plain text instead of crashing. - logger.debug( - "Image reference '%s' could not be resolved to an existing file. Treating as plain text.", - image, - ) - return {"type": "text", "text": image} - except (OSError, ValueError) as e: - # Path operations can fail when the filename exceeds OS limits (e.g., Linux 255-char name limit) - # or contains invalid characters. Treat as plain text rather than crashing. + inlined_uri = local_to_base64(local_file, mime_type) + except InvalidInputError as e: + # Local references can be unresolvable Document Intelligence output or unsafe paths. Preserve + # the original markdown as text instead of failing the evaluation or reading outside the Prompty directory. logger.debug( "Image reference '%s' could not be resolved to a valid path (%s). Treating as plain text.", image, e, ) return {"type": "text", "text": image} - inlined_uri = local_to_base64(local_file, mime_type) if not inlined_uri: raise InvalidInputError(f"Failed to determine how to inline the following image URL '{image}'") diff --git a/sdk/evaluation/azure-ai-evaluation/tests/unittests/test_inline_image_fallback.py b/sdk/evaluation/azure-ai-evaluation/tests/unittests/test_inline_image_fallback.py index 0d60b32fe24c..7b0190cc7b71 100644 --- a/sdk/evaluation/azure-ai-evaluation/tests/unittests/test_inline_image_fallback.py +++ b/sdk/evaluation/azure-ai-evaluation/tests/unittests/test_inline_image_fallback.py @@ -7,6 +7,7 @@ import pytest +from azure.ai.evaluation._legacy.prompty._exceptions import InvalidInputError from azure.ai.evaluation._legacy.prompty._utils import _inline_image, _to_content_str_or_list @@ -96,6 +97,80 @@ def test_real_local_image_file(self, tmp_path): assert result["image_url"]["url"].startswith("data:") assert "base64" in result["image_url"]["url"] + def test_parent_traversal_returns_text(self, tmp_path): + """A local image reference cannot escape the Prompty directory.""" + working_dir = tmp_path / "prompty" + working_dir.mkdir() + (tmp_path / "outside.png").write_bytes(b"outside") + image = "![test](../outside.png)" + + result = _inline_image(image, working_dir, "auto") + + assert result == {"type": "text", "text": image} + + def test_mixed_separator_parent_traversal_returns_text(self, tmp_path): + """Windows separators cannot bypass containment on other platforms.""" + working_dir = tmp_path / "prompty" + working_dir.mkdir() + (tmp_path / "outside.png").write_bytes(b"outside") + image = r"![test](..\outside.png)" + + result = _inline_image(image, working_dir, "auto") + + assert result == {"type": "text", "text": image} + + def test_absolute_path_returns_text(self, tmp_path): + """Absolute paths are not valid local Prompty image references.""" + image_path = tmp_path / "inside.png" + image_path.write_bytes(b"inside") + image = f"![test]({image_path.as_posix()})" + + result = _inline_image(image, tmp_path, "auto") + + assert result == {"type": "text", "text": image} + + def test_windows_absolute_path_returns_text(self, tmp_path): + """Windows drive paths are rejected on every operating system.""" + image = r"![test](C:\outside.png)" + + result = _inline_image(image, tmp_path, "auto") + + assert result == {"type": "text", "text": image} + + def test_symlink_escape_returns_text(self, tmp_path): + """A symlink inside the Prompty directory cannot target an outside file.""" + working_dir = tmp_path / "prompty" + working_dir.mkdir() + outside_path = tmp_path / "outside.png" + outside_path.write_bytes(b"outside") + symlink_path = working_dir / "linked.png" + try: + symlink_path.symlink_to(outside_path) + except (NotImplementedError, OSError): + pytest.skip("Creating symlinks is not supported in this test environment.") + + result = _inline_image("![test](linked.png)", working_dir, "auto") + + assert result == {"type": "text", "text": "![test](linked.png)"} + + def test_data_path_parent_traversal_raises_invalid_input(self, tmp_path): + """The explicit data path form rejects paths outside the Prompty directory.""" + working_dir = tmp_path / "prompty" + working_dir.mkdir() + (tmp_path / "outside.png").write_bytes(b"outside") + + with pytest.raises(InvalidInputError, match="within the Prompty directory"): + _inline_image("![test](data:image/png;path:../outside.png)", working_dir, "auto") + + def test_data_path_local_image_file(self, tmp_path): + """The explicit data path form still inlines a file within the Prompty directory.""" + (tmp_path / "inside.png").write_bytes(b"inside") + + result = _inline_image("![test](data:image/png;path:inside.png)", tmp_path, "auto") + + assert result["type"] == "image_url" + assert result["image_url"]["url"] == f"data:image/png;base64,{base64.b64encode(b'inside').decode('utf-8')}" + @pytest.mark.unittest class TestToContentStrOrListGracefulFallback: @@ -110,6 +185,17 @@ def test_text_with_unresolvable_image_ref(self, tmp_path): for item in result: assert item["type"] == "text" + def test_text_with_existing_parent_image_ref(self, tmp_path): + """Mixed content cannot inline an existing image outside the Prompty directory.""" + working_dir = tmp_path / "prompty" + working_dir.mkdir() + (tmp_path / "outside.png").write_bytes(b"outside") + + result = _to_content_str_or_list("Before ![img](../outside.png) after", working_dir, "auto") + + assert isinstance(result, list) + assert all(item["type"] == "text" for item in result) + def test_plain_text_no_images(self, tmp_path): """Plain text with no image references should return a string.""" result = _to_content_str_or_list("Just plain text", tmp_path, "auto")