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
25 changes: 19 additions & 6 deletions src/labapi/entry/entries/attachment.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,23 @@ def _make_backing_io(use_tempfile: bool) -> IO[bytes]:
return TemporaryFile() if use_tempfile else BytesIO()


def _safe_basename(name: str | None) -> str | None:
r"""Reduce a possibly-hostile filename to a sanitized basename.

Rejects `None`/empty input and path-traversal components (`.`, `..`),
and treats backslashes as path separators so Windows-style traversal
sequences (e.g. ``..\..\x``) are also reduced correctly.
"""
if name is None:
return None

basename = PurePosixPath(name.replace("\\", "/")).name
if not basename.strip() or basename in {".", ".."}:
return None

return basename


def _s3_filename_from_url(url: str) -> str | None:
"""Return a filename from a redirected Amazon S3 object URL, if valid."""
parsed_url = urlsplit(url)
Expand All @@ -38,11 +55,7 @@ def _s3_filename_from_url(url: str) -> str | None:
if not path or path.endswith("/"):
return None

filename = PurePosixPath(path).name
if not filename.strip() or filename in {".", ".."}:
return None

return filename
return _safe_basename(path)


class AttachmentEntry(Entry[Attachment], part_type="Attachment"):
Expand Down Expand Up @@ -98,7 +111,7 @@ def _ensure_attachment(self, use_tempfile: bool) -> None:
msg["Content-Disposition"] = content_disposition

mime_type = msg.get_content_type()
filename = self._filename or msg.get_filename()
filename = self._filename or _safe_basename(msg.get_filename())
if filename is not None and not filename.strip():
filename = None
if filename is None and attachment_stream.response.history:
Expand Down
32 changes: 32 additions & 0 deletions tests/entry/entries/test_attachment.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,38 @@ def test_attachment_entry_uses_s3_redirect_path_for_filename(
assert attachment.filename == "testfile 1GiB.bin"
assert attachment.read() == b"attachment data"

@pytest.mark.parametrize(
("raw_filename", "expected_filename"),
[
('"../../x"', "x"),
(r'"..\\..\\x"', "x"),
('"report.pdf"', "report.pdf"),
],
)
def test_attachment_entry_sanitizes_content_disposition_filename(
self,
client,
user: User,
raw_filename: str,
expected_filename: str,
):
"""Content-Disposition filenames are reduced to a safe basename."""
entry = AttachmentEntry("eid_att", "Caption", user)

mock_response = Mock()
mock_response.headers = {
"Content-Type": "application/octet-stream",
"Content-Disposition": f"attachment; filename={raw_filename}",
}
mock_response.history = []
mock_response.iter_content.return_value = [b"attachment data"]
client.stream_api_get = Mock(return_value=StreamingResponse(mock_response))

attachment = entry.get_attachment()

assert attachment.filename == expected_filename
assert attachment.read() == b"attachment data"

@pytest.mark.parametrize(
("content_type", "expected_filename"),
[
Expand Down