Skip to content

Commit ba02626

Browse files
romanlutzCopilot
andauthored
MAINT: Standardize path handling on pathlib (microsoft#1815)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fecc545 commit ba02626

7 files changed

Lines changed: 140 additions & 77 deletions

File tree

pyrit/backend/mappers/attack_mappers.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313

1414
import logging
1515
import mimetypes
16-
import os
1716
import time
1817
import uuid
1918
from datetime import datetime, timedelta, timezone
19+
from pathlib import Path
2020
from typing import TYPE_CHECKING, Optional, cast
2121
from urllib.parse import quote, urlparse
2222

@@ -178,7 +178,7 @@ def _resolve_media_url(*, value: Optional[str], data_type: str) -> Optional[str]
178178
if value.startswith(("http://", "https://", "data:")):
179179
return value
180180
# Local file path — construct a media endpoint URL
181-
if os.path.isfile(value):
181+
if Path(value).is_file():
182182
return f"/api/media?path={quote(str(value))}"
183183
return value
184184

@@ -373,7 +373,7 @@ def _build_filename(
373373
source = value
374374
if source.startswith("http"):
375375
source = urlparse(source).path
376-
ext = os.path.splitext(source)[1] # e.g. ".png"
376+
ext = Path(source).suffix # e.g. ".png"
377377

378378
if not ext:
379379
# Fallback: guess from mime type based on data type prefix

pyrit/backend/routes/media.py

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212

1313
import logging
1414
import mimetypes
15-
import os
1615
from pathlib import Path
1716

1817
from fastapi import APIRouter, HTTPException, Query
@@ -61,39 +60,40 @@
6160
}
6261

6362

64-
def _validate_media_path(*, path: str, allowed_root: str) -> str:
63+
def _validate_media_path(*, path: str, allowed_root: Path) -> Path:
6564
"""
6665
Validate and sanitize a user-provided file path against an allowed root directory.
6766
68-
Uses ``os.path.realpath`` to resolve symlinks and ``..`` components, then
69-
verifies the canonical path starts with the allowed root prefix. This is
70-
the standard sanitization pattern recognized by static analysis tools
71-
(e.g. CodeQL ``py/path-injection``).
67+
Uses ``Path.resolve()`` to resolve symlinks and ``..`` components, then
68+
verifies the canonical path is under the allowed root. This is the standard
69+
sanitization pattern recognized by static analysis tools (e.g. CodeQL
70+
``py/path-injection``).
7271
7372
Args:
7473
path: The user-provided file path to validate.
75-
allowed_root: The canonical (``realpath``-resolved) allowed root directory.
74+
allowed_root: The canonical (``resolve``-d) allowed root directory.
7675
7776
Returns:
7877
The canonical, validated file path.
7978
8079
Raises:
8180
HTTPException 403: If the path fails any validation check.
8281
"""
83-
real_path = os.path.realpath(path)
84-
allowed_prefix = allowed_root + os.sep
82+
real_path = Path(path).resolve(strict=False)
8583

86-
if not real_path.startswith(allowed_prefix):
87-
raise HTTPException(status_code=403, detail="Access denied: path is outside the allowed results directory.")
84+
try:
85+
relative_parts = real_path.relative_to(allowed_root).parts
86+
except ValueError as exc:
87+
raise HTTPException(
88+
status_code=403, detail="Access denied: path is outside the allowed results directory."
89+
) from exc
8890

8991
# Restrict to known media subdirectories (e.g. prompt-memory-entries/)
90-
relative_parts = Path(os.path.relpath(real_path, allowed_root)).parts
9192
if not relative_parts or relative_parts[0] not in _ALLOWED_SUBDIRECTORIES:
9293
raise HTTPException(status_code=403, detail="Access denied: path is not in a media subdirectory.")
9394

9495
# Only allow known media file extensions
95-
_, ext = os.path.splitext(real_path)
96-
if ext.lower() not in _ALLOWED_EXTENSIONS:
96+
if real_path.suffix.lower() not in _ALLOWED_EXTENSIONS:
9797
raise HTTPException(status_code=403, detail="Access denied: file type is not allowed.")
9898

9999
return real_path
@@ -125,13 +125,13 @@ async def serve_media_async(
125125
memory = CentralMemory.get_memory_instance()
126126
if not memory.results_path:
127127
raise HTTPException(status_code=500, detail="Memory results_path is not configured.")
128-
allowed_root = os.path.realpath(memory.results_path)
128+
allowed_root = Path(memory.results_path).resolve(strict=False)
129129
except Exception as exc:
130130
raise HTTPException(status_code=500, detail="Memory not initialized; cannot determine results path.") from exc
131131

132132
validated_path = _validate_media_path(path=path, allowed_root=allowed_root)
133133

134-
if not os.path.isfile(validated_path):
134+
if not validated_path.is_file():
135135
raise HTTPException(status_code=404, detail="File not found.")
136136

137137
mime_type, _ = mimetypes.guess_type(validated_path)

pyrit/models/storage_io.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from __future__ import annotations
55

66
import logging
7-
import os
87
from abc import ABC, abstractmethod
98
from enum import Enum
109
from pathlib import Path
@@ -110,7 +109,7 @@ async def path_exists(self, path: Union[Path, str]) -> bool:
110109
111110
"""
112111
path = self._convert_to_path(path)
113-
return os.path.exists(path)
112+
return path.exists()
114113

115114
async def is_file(self, path: Union[Path, str]) -> bool:
116115
"""
@@ -124,7 +123,7 @@ async def is_file(self, path: Union[Path, str]) -> bool:
124123
125124
"""
126125
path = self._convert_to_path(path)
127-
return os.path.isfile(path)
126+
return path.is_file()
128127

129128
async def create_directory_if_not_exists(self, path: Union[Path, str]) -> None:
130129
"""
@@ -136,7 +135,7 @@ async def create_directory_if_not_exists(self, path: Union[Path, str]) -> None:
136135
"""
137136
directory_path = self._convert_to_path(path)
138137
if not directory_path.exists():
139-
os.makedirs(directory_path, exist_ok=True)
138+
directory_path.mkdir(parents=True, exist_ok=True)
140139

141140
def _convert_to_path(self, path: Union[Path, str]) -> Path:
142141
"""

pyrit/output/conversation/markdown.py

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import contextlib
55
import logging
66
import os
7+
from pathlib import Path
78

89
from pyrit.models import Message, MessagePiece, Score
910
from pyrit.output.conversation.base import ConversationPrinterBase
@@ -224,11 +225,13 @@ def _format_image_content(self, *, image_path: str) -> list[str]:
224225
@staticmethod
225226
def _format_link_path(path: str) -> str:
226227
"""Return a markdown-friendly link (POSIX separators, relative if possible)."""
228+
path_obj = Path(path)
227229
try:
228-
relative_path = os.path.relpath(path)
230+
relative_path = str(path_obj.relative_to(Path.cwd()))
229231
except ValueError:
230-
# Different mount/drive than cwd (Windows). Fall back to the absolute path.
231-
relative_path = os.path.abspath(path)
232+
# Path is not under cwd (different drive on Windows, or simply outside cwd).
233+
# Fall back to the absolute path.
234+
relative_path = str(path_obj.resolve())
232235
return relative_path.replace("\\", "/")
233236

234237
def _maybe_blur_image_on_disk(self, *, image_path: str) -> str | None:
@@ -251,30 +254,30 @@ def _maybe_blur_image_on_disk(self, *, image_path: str) -> str | None:
251254
str | None: The path to the blurred image, or ``None`` on failure.
252255
"""
253256
try:
254-
blurred_path = self._blurred_destination(image_path=image_path)
255-
if os.path.exists(blurred_path):
257+
blurred_path = Path(self._blurred_destination(image_path=image_path))
258+
if blurred_path.exists():
256259
logger.debug(f"Reusing cached blurred image at {blurred_path}")
257-
return blurred_path
260+
return str(blurred_path)
258261

259-
os.makedirs(os.path.dirname(blurred_path) or ".", exist_ok=True)
262+
blurred_path.parent.mkdir(parents=True, exist_ok=True)
260263

261264
from pyrit.output._image_utils import blur_image_bytes
262265

263266
with open(image_path, "rb") as f:
264267
original_bytes = f.read()
265268
blurred_bytes = blur_image_bytes(image_bytes=original_bytes, radius=self._blur_radius)
266269

267-
temp_path = f"{blurred_path}.tmp.{os.getpid()}"
270+
temp_path = blurred_path.parent / f"{blurred_path.name}.tmp.{os.getpid()}"
268271
try:
269272
with open(temp_path, "wb") as f:
270273
f.write(blurred_bytes)
271274
os.replace(temp_path, blurred_path)
272275
except Exception:
273-
if os.path.exists(temp_path):
276+
if temp_path.exists():
274277
with contextlib.suppress(OSError):
275-
os.remove(temp_path)
278+
temp_path.unlink()
276279
raise
277-
return blurred_path
280+
return str(blurred_path)
278281
except Exception as exc:
279282
logger.warning(f"Failed to write blurred image for {image_path}; falling back to a text link. Error: {exc}")
280283
return None
@@ -289,9 +292,9 @@ def _blurred_destination(self, *, image_path: str) -> str:
289292
Returns:
290293
str: Path to the blurred file (sibling by default, or under ``blurred_dir``).
291294
"""
292-
directory = self._blurred_dir if self._blurred_dir is not None else os.path.dirname(image_path)
293-
stem = os.path.splitext(os.path.basename(image_path))[0]
294-
return os.path.join(directory, f"{stem}_blurred.png")
295+
image_path_obj = Path(image_path)
296+
directory = Path(self._blurred_dir) if self._blurred_dir is not None else image_path_obj.parent
297+
return str(directory / f"{image_path_obj.stem}_blurred.png")
295298

296299
def _format_audio_content(self, *, audio_path: str) -> list[str]:
297300
"""

tests/unit/backend/test_media_route.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def test_rejects_path_outside_results_directory(self, client: TestClient, _mock_
6161
try:
6262
response = client.get("/api/media", params={"path": outside_path})
6363
assert response.status_code == 403
64+
assert "outside the allowed results directory" in response.json()["detail"]
6465
finally:
6566
os.unlink(outside_path)
6667

@@ -69,6 +70,34 @@ def test_rejects_path_traversal(self, client: TestClient, _mock_memory: Path) ->
6970
traversal_path = str(_mock_memory / ".." / ".." / "etc" / "passwd")
7071
response = client.get("/api/media", params={"path": traversal_path})
7172
assert response.status_code == 403
73+
assert "outside the allowed results directory" in response.json()["detail"]
74+
75+
def test_rejects_symlink_pointing_outside_results(self, client: TestClient, _mock_memory: Path) -> None:
76+
"""A symlink under an allowed subdirectory that points outside the results dir is rejected.
77+
78+
``Path.resolve()`` resolves symlinks, so a symlink that targets a file outside the
79+
allowed root must be rejected just like a plain path traversal attempt.
80+
"""
81+
# Create a file outside the allowed directory
82+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
83+
tmp.write(b"\x89PNG\r\n\x1a\n")
84+
outside_target = tmp.name
85+
86+
try:
87+
symlink_path = _mock_memory / "prompt-memory-entries" / "evil_symlink.png"
88+
try:
89+
os.symlink(outside_target, symlink_path)
90+
except (OSError, NotImplementedError) as exc:
91+
# Symlink creation may fail on Windows without admin / developer mode.
92+
pytest.skip(f"Cannot create symlink in this environment: {exc}")
93+
94+
response = client.get("/api/media", params={"path": str(symlink_path)})
95+
assert response.status_code == 403
96+
# Confirm the rejection reason is the symlink-escape check specifically,
97+
# not one of the other 403 paths (subdirectory / extension).
98+
assert "outside the allowed results directory" in response.json()["detail"]
99+
finally:
100+
os.unlink(outside_target)
72101

73102
def test_returns_404_for_nonexistent_file(self, client: TestClient, _mock_memory: Path) -> None:
74103
"""Non-existent files under allowed subdirectory return 404."""

tests/unit/models/test_storage_io.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,30 +51,30 @@ async def test_disk_storage_io_path_exists():
5151
storage = DiskStorageIO()
5252
path = "sample.txt"
5353

54-
with patch("os.path.exists", return_value=True) as mock_exists:
54+
with patch("pathlib.Path.exists", return_value=True) as mock_exists:
5555
result = await storage.path_exists(path)
5656
assert result is True
57-
mock_exists.assert_called_once_with(Path(path))
57+
mock_exists.assert_called_once()
5858

5959

6060
async def test_disk_storage_io_is_file():
6161
storage = DiskStorageIO()
6262
path = "sample.txt"
6363

64-
with patch("os.path.isfile", return_value=True) as mock_isfile:
64+
with patch("pathlib.Path.is_file", return_value=True) as mock_isfile:
6565
result = await storage.is_file(path)
6666
assert result is True
67-
mock_isfile.assert_called_once_with(Path(path))
67+
mock_isfile.assert_called_once()
6868

6969

7070
async def test_disk_storage_io_create_directory_if_not_exists():
7171
storage = DiskStorageIO()
7272
directory_path = "sample_dir"
7373

74-
with patch("os.makedirs") as mock_mkdir, patch("pathlib.Path.exists", return_value=False) as mock_exists:
74+
with patch("pathlib.Path.mkdir") as mock_mkdir, patch("pathlib.Path.exists", return_value=False) as mock_exists:
7575
await storage.create_directory_if_not_exists(directory_path)
7676
mock_exists.assert_called_once()
77-
mock_mkdir.assert_called_once_with(Path(directory_path), exist_ok=True)
77+
mock_mkdir.assert_called_once_with(parents=True, exist_ok=True)
7878

7979

8080
async def test_azure_blob_storage_io_read_file(azure_blob_storage_io):

0 commit comments

Comments
 (0)