Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions sdk/evaluation/azure-ai-evaluation/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Comment on lines +386 to +388
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}"
Expand Down Expand Up @@ -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}'")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand All @@ -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")
Expand Down
Loading