From 042b0c70531b229af3acbd0421a2f23098d848b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 04:05:48 -0700 Subject: [PATCH] Fix path-traversal vulnerability in attachment filename parsing (#1455) Normalize backslashes to slashes and reject residual URL-encoded traversal sequences in _safe_filename before taking Path(...).name, closing a Windows-style and percent-encoded path-traversal vector in the attachment parser. --- .jules/sentinel.md | 5 +++++ backend/services/attachment_parser.py | 17 ++++++++++++++-- backend/tests/test_attachment_parser.py | 26 +++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 6f502e1c7..9208f58b1 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -133,3 +133,8 @@ **Vulnerability:** The `in_reply_to` and `references` fields on the `SendEmailRequest` model lacked explicit validation, opening up an opportunity for header injection by appending `\r\n`. **Learning:** While the email service internally checks some headers, relying on the API boundary's Pydantic model ensures bad input is stopped early and consistently. Pydantic regex patterns aren't sufficient on their own for all string contexts due to encoding/decoding inconsistencies. **Prevention:** Always use `@field_validator` with explicit `mode="before"` string matching to reject `chr(10)` and `chr(13)` across all user-controlled email header fields. Use `isinstance(value, str)` before string operations to prevent runtime errors if input is missing or malformed. + +## 2026-08-05 - [Prevent Path Traversal via Backslashes in Attachment Parser] +**Vulnerability:** The `_safe_filename` function in `backend/services/attachment_parser.py` used `pathlib.Path().name` to strip directory components from attachment filenames, but failed to normalize backslashes beforehand. This allowed attackers to use Windows-style path separators (e.g., `..\..\upload`) to bypass path validation on POSIX systems. +**Learning:** Checking for traversal sequences using `pathlib.Path().name` may leave the result vulnerable if the input path can contain Windows-style path separators but the program interprets it dynamically or decodes payloads using backslashes, because POSIX `pathlib` treats backslashes as valid filename characters, not separators. +**Prevention:** Always convert backslashes to forward slashes before parsing filenames using `pathlib.Path().name`. diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index 868b9b183..7359d6b2f 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Any +from urllib.parse import unquote from .text_safety import strip_html_markup @@ -16,6 +17,7 @@ } MAX_ATTACHMENT_PARSE_SOURCE_CHARS = 1_000_000 MAX_ATTACHMENT_PARSE_SOURCE_BYTES = 20 * 1024 * 1024 +MAX_ATTACHMENT_FILENAME_DECODE_ROUNDS = 3 @dataclass(frozen=True) @@ -266,8 +268,19 @@ def _parser_key_for(parse_content_type: str, parse_status: str) -> str: def _safe_filename(filename: str | None) -> str: """Return a basename-only attachment display filename.""" - display_filename = strip_html_markup(_sanitize_nul(filename or "attachment")) - display_filename = Path(display_filename).name.strip() + display_filename = filename or "attachment" + for _ in range(MAX_ATTACHMENT_FILENAME_DECODE_ROUNDS): + decoded_filename = unquote(display_filename) + if decoded_filename == display_filename: + break + display_filename = decoded_filename + # Entity-encoded percent escapes (for example ``%2e``) only become + # literal ``%`` sequences during markup decoding, so the residual-encoding + # guard must run after ``strip_html_markup`` to stay fail-closed. + display_filename = strip_html_markup(_sanitize_nul(display_filename)) + if unquote(display_filename) != display_filename: + return "attachment" + display_filename = Path(display_filename.replace("\\", "/")).name.strip() if display_filename in {"", ".", ".."}: return "attachment" return display_filename diff --git a/backend/tests/test_attachment_parser.py b/backend/tests/test_attachment_parser.py index ad2dd892d..4eeb27228 100644 --- a/backend/tests/test_attachment_parser.py +++ b/backend/tests/test_attachment_parser.py @@ -3,6 +3,7 @@ import pytest from services.attachment_parser import ( + _safe_filename, MAX_ATTACHMENT_PARSE_SOURCE_BYTES, MAX_ATTACHMENT_PARSE_SOURCE_CHARS, decode_deferred_attachment_payload, @@ -255,3 +256,28 @@ def test_deferred_pdf_decoder_rejects_non_pdf_and_oversized_payloads(monkeypatch oversized = base64.b64encode(b"%PDF-1.7").decode("ascii") with pytest.raises(ValueError, match="size limit"): decode_deferred_attachment_payload(oversized) + + +def test_safe_filename_handles_windows_path_traversal(): + assert _safe_filename("..\\..\\upload.txt") == "upload.txt" + assert _safe_filename("C:\\mail\\report.pdf") == "report.pdf" + assert _safe_filename("%5c%2e%2e%5csecret.txt") == "secret.txt" + assert _safe_filename("%252e%252e%252fsecret.txt") == "secret.txt" + assert _safe_filename("%252525252e%252525252e%252525252fsecret.txt") == "attachment" + + +def test_safe_filename_fails_closed_after_entity_decoding(): + """Entity-encoded percent escapes must trip the residual guard post-decode.""" + assert _safe_filename("%2e%2e%2fsecret.txt") == "attachment" + + +def test_safe_filename_plain_percent_encoded_traversal_still_decodes_to_basename(): + """Single percent-encoded traversal still decodes in-round to its basename.""" + assert _safe_filename("%2e%2e%2fsecret.txt") == "secret.txt" + + +def test_safe_filename_benign_name_survives_unchanged(): + assert _safe_filename("annual-report-2026.pdf") == "annual-report-2026.pdf" + assert _safe_filename("quarterly report & notes.pdf") == ( + "quarterly report & notes.pdf" + )