Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3d401b3
test(attachments): preserve literal percent filename identity
seonghobae Sep 2, 2026
e6565ae
fix(attachments): preserve MIME filename percent identity
seonghobae Sep 2, 2026
0b01f6b
test(attachments): align filename identity contract
seonghobae Sep 2, 2026
63fa5d7
test(attachments): reject filename entity parser smuggling
seonghobae Sep 2, 2026
c4140c1
fix(attachments): stop filename entity re-decoding
seonghobae Sep 2, 2026
f2626bc
test(attachments): align filename identity contract
seonghobae Sep 2, 2026
dd6976d
test(attachments): reject unknown angle-bracket parser smuggling
seonghobae Sep 2, 2026
1261589
fix(attachments): reject raw angle-bracket filename authority
seonghobae Sep 2, 2026
026a8e8
test(attachments): reject whitespace-created parser authority
seonghobae Sep 2, 2026
c32786a
fix(attachments): preserve trailing filename whitespace identity
seonghobae Sep 2, 2026
ab263a9
revert(attachments): keep parser-boundary whitespace fix evidence rea…
seonghobae Sep 2, 2026
232f644
chore(tests): restore reachable MIME filename coverage
seonghobae Sep 2, 2026
e68c017
test(attachments): reproduce NUL-created parser authority from EML
seonghobae Sep 2, 2026
f17846e
fix(attachments): reject NUL-bearing filename authority
seonghobae Sep 2, 2026
40eb882
test(attachments): separate display filename from parser authority
seonghobae Sep 2, 2026
0e7df51
test(attachments): preserve sanitized display filename contract
seonghobae Sep 2, 2026
fea71c7
fix(attachments): decouple display filename from parser authority
seonghobae Sep 2, 2026
288136e
test(attachments): reject MIME filename control chars
seonghobae Sep 2, 2026
92ec615
fix(attachments): fail closed on MIME filename controls
seonghobae Sep 2, 2026
4a7b6ec
docs(attachments): trace MIME filename parser authority
seonghobae Sep 2, 2026
51f4549
test(attachments): reject bidi control filename identity
seonghobae Sep 3, 2026
ddc1159
fix(attachments): reject bidi control filename identity
seonghobae Sep 3, 2026
2eaf613
docs(attachments): trace bidi filename control boundary
seonghobae Sep 3, 2026
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
76 changes: 60 additions & 16 deletions backend/services/attachment_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import unquote

from .text_safety import strip_html_markup
from .text_safety import contains_html_markup, strip_html_markup

_GENERIC_CONTENT_TYPES = {
"",
Expand All @@ -17,7 +16,6 @@
}
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)
Expand Down Expand Up @@ -119,6 +117,15 @@ class AttachmentParserDescriptor:
or descriptor.parse_status in _DEFERRED_PARSE_STATUSES
for extension in descriptor.extensions
}
_BIDI_FILENAME_CONTROL_CODEPOINTS = frozenset(
{
0x061C,
0x200E,
0x200F,
*range(0x202A, 0x202F),
*range(0x2066, 0x206A),
}
)


@dataclass(frozen=True)
Expand Down Expand Up @@ -148,9 +155,10 @@ def parse_email_attachment(
) -> AttachmentParseResult:
"""Classify and normalize one attachment without running heavy parsers."""
safe_filename = _safe_filename(filename)
parser_filename = _parser_authority_filename(filename)
normalized_content_type = _normalize_content_type(content_type)
parse_content_type = _parse_content_type_for(
safe_filename,
parser_filename,
normalized_content_type,
)

Expand Down Expand Up @@ -266,26 +274,62 @@ def _parser_key_for(parse_content_type: str, parse_status: str) -> str:
return "unsupported_binary"


def _has_unsafe_filename_control(filename: str) -> bool:
"""Return whether a MIME filename contains unsafe control semantics."""
return any(
(codepoint := ord(character)) < 0x20
or 0x7F <= codepoint <= 0x9F
or codepoint in _BIDI_FILENAME_CONTROL_CODEPOINTS
for character in filename
)


def _safe_filename(filename: str | None) -> str:
"""Return a basename-only attachment display filename."""
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 ``&#37;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 a basename-only filename projection safe for display and storage.

Display sanitization is intentionally separate from parser selection. Known
markup is stripped so active HTML cannot reach UI-facing attachment fields,
while raw angle-bracket labels and control-bearing names fail closed.
Percent and character-reference text that is not markup remains literal.
"""
raw_filename = filename or "attachment"
if _has_unsafe_filename_control(raw_filename):
return "attachment"
if contains_html_markup(raw_filename):
display_filename = strip_html_markup(raw_filename)
elif "<" in raw_filename or ">" in raw_filename:
return "attachment"
else:
display_filename = raw_filename
display_filename = Path(display_filename.replace("\\", "/")).name.strip()
if display_filename in {"", ".", ".."}:
return "attachment"
return display_filename


def _parser_authority_filename(filename: str | None) -> str:
"""Return the literal basename eligible to select a parser by extension.

MIME filename identity is neither HTML nor URL source. Parser authority must
therefore use the pre-display representation: semantic decoding, markup
stripping, control deletion, or whitespace trimming must never manufacture
a recognized suffix for a generic MIME type.
"""
raw_filename = filename or "attachment"
if _has_unsafe_filename_control(raw_filename):
return "attachment"
if (
"<" in raw_filename
or ">" in raw_filename
or contains_html_markup(raw_filename)
):
return "attachment"
authority_filename = Path(raw_filename.replace("\\", "/")).name
if authority_filename in {"", ".", ".."}:
return "attachment"
return authority_filename


def _coerce_deferred_payload_bytes(raw_content: Any) -> bytes:
"""Return the exact byte payload retained for deferred recognition."""
if isinstance(raw_content, bytes):
Expand Down
201 changes: 201 additions & 0 deletions backend/tests/test_attachment_filename_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
"""Regression contracts for MIME attachment filename identity."""

from services.attachment_parser import _safe_filename, parse_email_attachment
from services.email_parser import parse_eml_bytes


def test_html_entity_dot_does_not_change_generic_mime_parser() -> None:
"""HTML entity syntax is literal MIME filename text, not an extension codec."""
result = parse_email_attachment(
filename="quarterly&#46;json",
content_type="application/octet-stream",
raw_content=b'{"project":"Launch"}',
)

assert result.filename == "quarterly&#46;json"
assert result.parse_content_type == "application/octet-stream"
assert result.parser_key == "unsupported_binary"
assert result.parse_status == "unsupported_content_type"


def test_html_display_sanitization_cannot_smuggle_generic_mime_extension() -> None:
"""Safe display projection must not become parser-selection authority."""
result = parse_email_attachment(
filename="<b>quarterly</b>.json",
content_type="application/octet-stream",
raw_content=b'{"project":"Launch"}',
)

assert result.filename == "quarterly.json"
assert result.parse_content_type == "application/octet-stream"
assert result.parser_key == "unsupported_binary"
assert result.parse_status == "unsupported_content_type"


def test_unknown_angle_bracket_filename_cannot_select_generic_parser() -> None:
"""Unknown tag-shaped filename text must not become extension authority."""
result = parse_email_attachment(
filename="<Q4>.json",
content_type="application/octet-stream",
raw_content=b'{"project":"Launch"}',
)

assert result.filename == "attachment"
assert result.parse_content_type == "application/octet-stream"
assert result.parser_key == "unsupported_binary"
assert result.parse_status == "unsupported_content_type"


def test_nul_filename_cannot_create_generic_mime_extension_authority() -> None:
"""A production EML NUL must not disappear into a parser-recognized suffix."""
parsed = parse_eml_bytes(
b"Message-ID: <nul-filename@test.com>\r\n"
b"From: sender@test.com\r\n"
b"To: recipient@test.com\r\n"
b"Subject: NUL filename\r\n"
b"Date: Mon, 27 Apr 2026 10:00:00 +0000\r\n"
b'Content-Type: multipart/mixed; boundary="mixed-boundary"\r\n'
b"\r\n"
b"--mixed-boundary\r\n"
b"Content-Type: text/plain; charset=utf-8\r\n"
b"\r\n"
b"See attached.\r\n"
b"--mixed-boundary\r\n"
b"Content-Type: application/octet-stream\r\n"
b'Content-Disposition: attachment; filename="quarterly.json\x00"\r\n'
b"\r\n"
b'{"project":"Launch"}\r\n'
b"--mixed-boundary--\r\n"
)

attachment = parsed["attachments"][0]
assert attachment["filename"] == "attachment"
assert attachment["parse_content_type"] == "application/octet-stream"
assert attachment["parser_key"] == "unsupported_binary"
assert attachment["parse_status"] == "unsupported_content_type"


def test_rfc2231_control_character_filename_fails_closed() -> None:
"""RFC 2231 decoding must not turn control-bearing names into parser authority."""
parsed = parse_eml_bytes(
b"Message-ID: <control-filename@test.com>\r\n"
b"From: sender@test.com\r\n"
b"To: recipient@test.com\r\n"
b"Subject: Control filename\r\n"
b"Date: Mon, 27 Apr 2026 10:00:00 +0000\r\n"
b'Content-Type: multipart/mixed; boundary="mixed-boundary"\r\n'
b"\r\n"
b"--mixed-boundary\r\n"
b"Content-Type: text/plain; charset=utf-8\r\n"
b"\r\n"
b"See attached.\r\n"
b"--mixed-boundary\r\n"
b"Content-Type: application/octet-stream\r\n"
b"Content-Disposition: attachment; "
b"filename*=utf-8''quarterly%0A.json\r\n"
b"\r\n"
b'{"project":"Launch"}\r\n'
b"--mixed-boundary--\r\n"
)

attachment = parsed["attachments"][0]
assert attachment["filename"] == "attachment"
assert attachment["parse_content_type"] == "application/octet-stream"
assert attachment["parser_key"] == "unsupported_binary"
assert attachment["parse_status"] == "unsupported_content_type"


def test_filename_controls_fail_closed_before_display_or_parser_selection() -> None:
"""C0/C1 controls are not valid display or parser-authority characters."""
for control in ("\t", "\x1b", "\x7f", "\x85"):
filename = f"quarterly{control}.json"
result = parse_email_attachment(
filename=filename,
content_type="application/octet-stream",
raw_content=b'{"project":"Launch"}',
)

assert _safe_filename(filename) == "attachment"
assert result.filename == "attachment"
assert result.parse_content_type == "application/octet-stream"
assert result.parser_key == "unsupported_binary"
assert result.parse_status == "unsupported_content_type"


def test_rfc2231_bidi_control_filename_fails_closed() -> None:
"""RFC 2231 decoding must not retain invisible display-order authority."""
parsed = parse_eml_bytes(
b"Message-ID: <bidi-filename@test.com>\r\n"
b"From: sender@test.com\r\n"
b"To: recipient@test.com\r\n"
b"Subject: Bidi filename\r\n"
b"Date: Mon, 27 Apr 2026 10:00:00 +0000\r\n"
b'Content-Type: multipart/mixed; boundary="mixed-boundary"\r\n'
b"\r\n"
b"--mixed-boundary\r\n"
b"Content-Type: text/plain; charset=utf-8\r\n"
b"\r\n"
b"See attached.\r\n"
b"--mixed-boundary\r\n"
b"Content-Type: application/octet-stream\r\n"
b"Content-Disposition: attachment; "
b"filename*=utf-8''quarterly%E2%80%AEfdp.json\r\n"
b"\r\n"
b'{"project":"Launch"}\r\n'
b"--mixed-boundary--\r\n"
)

attachment = parsed["attachments"][0]
assert attachment["filename"] == "attachment"
assert attachment["parse_content_type"] == "application/octet-stream"
assert attachment["parser_key"] == "unsupported_binary"
assert attachment["parse_status"] == "unsupported_content_type"


def test_bidi_controls_fail_closed_without_rejecting_bidi_scripts() -> None:
"""Unicode Bidi_Control characters fail closed; ordinary RTL text remains valid."""
bidi_controls = (
"\u061c",
"\u200e",
"\u200f",
"\u202a",
"\u202b",
"\u202c",
"\u202d",
"\u202e",
"\u2066",
"\u2067",
"\u2068",
"\u2069",
)
for control in bidi_controls:
filename = f"quarterly{control}.json"
result = parse_email_attachment(
filename=filename,
content_type="application/octet-stream",
raw_content=b'{"project":"Launch"}',
)

assert _safe_filename(filename) == "attachment"
assert result.filename == "attachment"
assert result.parse_content_type == "application/octet-stream"
assert result.parser_key == "unsupported_binary"
assert result.parse_status == "unsupported_content_type"

rtl_filename = "تقرير-ربع-سنوي.json"
rtl_result = parse_email_attachment(
filename=rtl_filename,
content_type="application/octet-stream",
raw_content=b'{"project":"Launch"}',
)
assert _safe_filename(rtl_filename) == rtl_filename
assert rtl_result.filename == rtl_filename
assert rtl_result.parser_key == "json"
assert rtl_result.parse_status == "parsed"


def test_benign_ampersand_filename_remains_literal() -> None:
"""Ordinary filename punctuation remains unchanged."""
assert _safe_filename("quarterly report & notes.pdf") == (
"quarterly report & notes.pdf"
)
39 changes: 29 additions & 10 deletions backend/tests/test_attachment_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,22 +258,41 @@ def test_deferred_pdf_decoder_rejects_non_pdf_and_oversized_payloads(monkeypatch
decode_deferred_attachment_payload(oversized)


def test_safe_filename_handles_windows_path_traversal():
def test_literal_percent_escape_does_not_change_attachment_parser():
result = parse_email_attachment(
filename="quarterly%2Ejson",
content_type="application/octet-stream",
raw_content=b'{"project":"Launch"}',
)

assert result.filename == "quarterly%2Ejson"
assert result.parse_content_type == "application/octet-stream"
assert result.parser_key == "unsupported_binary"
assert result.parse_status == "unsupported_content_type"


def test_safe_filename_strips_literal_path_segments_without_percent_decoding():
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"
assert _safe_filename("%5c%2e%2e%5csecret.txt") == "%5c%2e%2e%5csecret.txt"
assert _safe_filename("%252e%252e%252fsecret.txt") == (
"%252e%252e%252fsecret.txt"
)
assert _safe_filename("%252525252e%252525252e%252525252fsecret.txt") == (
"%252525252e%252525252e%252525252fsecret.txt"
)


def test_safe_filename_fails_closed_after_entity_decoding():
"""Entity-encoded percent escapes must trip the residual guard post-decode."""
assert _safe_filename("&#37;2e&#37;2e&#37;2fsecret.txt") == "attachment"
def test_safe_filename_preserves_entity_encoded_percent_text():
"""MIME filename character references remain literal filename text."""
assert _safe_filename("&#37;2e&#37;2e&#37;2fsecret.txt") == (
"&#37;2e&#37;2e&#37;2fsecret.txt"
)


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_preserves_plain_percent_encoded_text():
"""MIME filenames are not URL paths and must not be percent-decoded again."""
assert _safe_filename("%2e%2e%2fsecret.txt") == "%2e%2e%2fsecret.txt"


def test_safe_filename_benign_name_survives_unchanged():
Expand Down
Loading
Loading