Skip to content

Commit ce5b825

Browse files
committed
fix: address PR review comments
- Add path traversal sanitization in save_bulk_data_file and download_rosbags_for_fault - Add SSRF protection: reject absolute URLs in download_bulk_data - Handle all non-2xx responses in get_bulk_data_info (not just 404) - Remove unused BulkDataDescriptor model
1 parent b829756 commit ce5b825

3 files changed

Lines changed: 30 additions & 39 deletions

File tree

src/ros2_medkit_mcp/client.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,10 +1000,10 @@ async def get_bulk_data_info(self, bulk_data_uri: str) -> dict[str, Any]:
10001000
client = await self._ensure_client()
10011001
response = await client.head(bulk_data_uri)
10021002

1003-
if response.status_code == 404:
1003+
if not response.is_success:
10041004
raise SovdClientError(
1005-
message=f"Bulk data not found: {bulk_data_uri}",
1006-
status_code=404,
1005+
message=f"Bulk data not found: {bulk_data_uri} (HTTP {response.status_code})",
1006+
status_code=response.status_code,
10071007
)
10081008

10091009
headers = response.headers
@@ -1027,11 +1027,18 @@ async def download_bulk_data(self, bulk_data_uri: str) -> tuple[bytes, str | Non
10271027
"""Download a bulk-data file.
10281028
10291029
Args:
1030-
bulk_data_uri: Full bulk-data URI path.
1030+
bulk_data_uri: Relative bulk-data URI path (must start with /).
10311031
10321032
Returns:
10331033
Tuple of (file_content, filename).
1034+
1035+
Raises:
1036+
ValueError: If the URI is an absolute URL (SSRF protection).
10341037
"""
1038+
# SSRF protection: reject absolute URLs - only allow relative paths
1039+
if bulk_data_uri.startswith(("http://", "https://", "//")):
1040+
raise ValueError(f"Absolute URLs not allowed for bulk data download: {bulk_data_uri}")
1041+
10351042
client = await self._ensure_client()
10361043
response = await client.get(bulk_data_uri, timeout=httpx.Timeout(300.0))
10371044

src/ros2_medkit_mcp/mcp_app.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ def save_bulk_data_file(
419419
Returns:
420420
Formatted TextContent list with download result.
421421
"""
422-
output_path = Path(output_dir)
422+
output_path = Path(output_dir).resolve()
423423
output_path.mkdir(parents=True, exist_ok=True)
424424

425425
# Generate filename if not provided
@@ -431,7 +431,16 @@ def save_bulk_data_file(
431431
if "." not in filename:
432432
filename += ".mcap"
433433

434-
file_path = output_path / filename
434+
# Sanitize filename to prevent path traversal
435+
safe_filename = Path(filename).name
436+
if not safe_filename:
437+
safe_filename = "download.mcap"
438+
439+
file_path = (output_path / safe_filename).resolve()
440+
# Ensure the resolved path is still within output_dir
441+
if not str(file_path).startswith(str(output_path)):
442+
raise ValueError(f"Path traversal detected in filename: {filename}")
443+
435444
file_path.write_bytes(content)
436445

437446
size_mb = len(content) / (1024 * 1024)
@@ -507,7 +516,7 @@ async def download_rosbags_for_fault(
507516
)
508517
]
509518

510-
output_path = Path(output_dir)
519+
output_path = Path(output_dir).resolve()
511520
output_path.mkdir(parents=True, exist_ok=True)
512521

513522
downloaded: list[str] = []
@@ -527,7 +536,13 @@ async def download_rosbags_for_fault(
527536
if not filename:
528537
filename = f"{snap_id}.mcap"
529538

530-
file_path = output_path / filename
539+
# Sanitize filename to prevent path traversal
540+
safe_filename = Path(filename).name or f"{snap_id}.mcap"
541+
file_path = (output_path / safe_filename).resolve()
542+
if not str(file_path).startswith(str(output_path)):
543+
errors.append(f" - {snap_id}: Path traversal detected in filename")
544+
continue
545+
531546
file_path.write_bytes(content)
532547

533548
size_mb = len(content) / (1024 * 1024)

src/ros2_medkit_mcp/models.py

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -521,37 +521,6 @@ class FreezeFrameSnapshot(BaseModel):
521521
model_config = {"populate_by_name": True, "extra": "allow"}
522522

523523

524-
class BulkDataDescriptor(BaseModel):
525-
"""Descriptor for bulk data (rosbag) with download URI."""
526-
527-
id: str = Field(..., description="Bulk data identifier")
528-
category: str | None = Field(
529-
default=None,
530-
description="Data category (e.g., 'rosbag', 'snapshot')",
531-
)
532-
bulk_data_uri: str = Field(
533-
...,
534-
alias="bulkDataUri",
535-
description="URI to download the bulk data file",
536-
)
537-
file_size: int | None = Field(
538-
default=None,
539-
alias="fileSize",
540-
description="File size in bytes",
541-
)
542-
is_available: bool = Field(
543-
default=True,
544-
alias="isAvailable",
545-
description="Whether the file is available for download",
546-
)
547-
timestamp: str | None = Field(
548-
default=None,
549-
description="ISO 8601 timestamp when the data was captured",
550-
)
551-
552-
model_config = {"populate_by_name": True, "extra": "allow"}
553-
554-
555524
class RosbagSnapshot(BaseModel):
556525
"""Rosbag snapshot with bulk data download URI."""
557526

0 commit comments

Comments
 (0)