From 3d401b327aeb017972d6f33251f7f2a3d9ec60d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:55:31 +0900 Subject: [PATCH 01/23] test(attachments): preserve literal percent filename identity --- backend/tests/test_attachment_parser.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/tests/test_attachment_parser.py b/backend/tests/test_attachment_parser.py index 4eeb27228..67310da12 100644 --- a/backend/tests/test_attachment_parser.py +++ b/backend/tests/test_attachment_parser.py @@ -258,6 +258,19 @@ def test_deferred_pdf_decoder_rejects_non_pdf_and_oversized_payloads(monkeypatch decode_deferred_attachment_payload(oversized) +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_handles_windows_path_traversal(): assert _safe_filename("..\\..\\upload.txt") == "upload.txt" assert _safe_filename("C:\\mail\\report.pdf") == "report.pdf" From e6565aeae8049b77994b49abca0256ca72d326fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:56:31 +0900 Subject: [PATCH 02/23] fix(attachments): preserve MIME filename percent identity --- backend/services/attachment_parser.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index 7359d6b2f..582fb0320 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -5,7 +5,6 @@ from dataclasses import dataclass from pathlib import Path from typing import Any -from urllib.parse import unquote from .text_safety import strip_html_markup @@ -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) @@ -267,19 +265,14 @@ 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 = 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" + """Return a basename-only attachment display filename without URL decoding. + + MIME filename parameters are already decoded by the email parser and are not + URL paths. Percent-decoding them here can change a literal filename extension + and therefore select a different attachment parser. Strip markup/NULs and + literal path separators only; preserve percent text as attachment identity. + """ + display_filename = strip_html_markup(_sanitize_nul(filename or "attachment")) display_filename = Path(display_filename.replace("\\", "/")).name.strip() if display_filename in {"", ".", ".."}: return "attachment" From 0b01f6ba305d49a2deb662480e2358f16e0bf089 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 22:57:26 +0900 Subject: [PATCH 03/23] test(attachments): align filename identity contract --- backend/tests/test_attachment_parser.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/backend/tests/test_attachment_parser.py b/backend/tests/test_attachment_parser.py index 67310da12..e59484750 100644 --- a/backend/tests/test_attachment_parser.py +++ b/backend/tests/test_attachment_parser.py @@ -271,22 +271,26 @@ def test_literal_percent_escape_does_not_change_attachment_parser(): assert result.parse_status == "unsupported_content_type" -def test_safe_filename_handles_windows_path_traversal(): +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("%2e%2e%2fsecret.txt") == "attachment" +def test_safe_filename_preserves_entity_decoded_percent_text(): + """HTML entities may normalize, but URL escapes stay literal filename text.""" + assert _safe_filename("%2e%2e%2fsecret.txt") == "%2e%2e%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(): From 63fa5d737afc1c4a22faf2f92628aaa3c3c51416 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 04:44:50 +0900 Subject: [PATCH 04/23] test(attachments): reject filename entity parser smuggling --- .../test_attachment_filename_identity.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 backend/tests/test_attachment_filename_identity.py diff --git a/backend/tests/test_attachment_filename_identity.py b/backend/tests/test_attachment_filename_identity.py new file mode 100644 index 000000000..592695237 --- /dev/null +++ b/backend/tests/test_attachment_filename_identity.py @@ -0,0 +1,38 @@ +"""Regression contracts for MIME attachment filename identity.""" + +from services.attachment_parser import _safe_filename, parse_email_attachment + + +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.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_html_markup_filename_cannot_smuggle_generic_mime_extension() -> None: + """Markup-looking filename text fails closed instead of selecting a parser.""" + result = parse_email_attachment( + filename="quarterly.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_benign_ampersand_filename_remains_literal() -> None: + """Ordinary filename punctuation remains unchanged.""" + assert _safe_filename("quarterly report & notes.pdf") == ( + "quarterly report & notes.pdf" + ) From c4140c125b7956d33593ddeddbf1c11df0171d89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 04:47:13 +0900 Subject: [PATCH 05/23] fix(attachments): stop filename entity re-decoding --- backend/services/attachment_parser.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index 582fb0320..de7458399 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any -from .text_safety import strip_html_markup +from .text_safety import contains_html_markup, strip_html_markup _GENERIC_CONTENT_TYPES = { "", @@ -265,14 +265,17 @@ 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 without URL decoding. + """Return a basename-only MIME filename without semantic re-decoding. - MIME filename parameters are already decoded by the email parser and are not - URL paths. Percent-decoding them here can change a literal filename extension - and therefore select a different attachment parser. Strip markup/NULs and - literal path separators only; preserve percent text as attachment identity. + MIME filename parameters reach this boundary as filename identity, not HTML + or URL text. Re-decoding percent escapes or character references can change + a literal suffix and therefore select a different parser for a generic MIME + type. Reject markup-looking names, remove only NULs and literal path + segments, and preserve all other filename text exactly. """ - display_filename = strip_html_markup(_sanitize_nul(filename or "attachment")) + display_filename = _sanitize_nul(filename or "attachment") + if contains_html_markup(display_filename): + return "attachment" display_filename = Path(display_filename.replace("\\", "/")).name.strip() if display_filename in {"", ".", ".."}: return "attachment" From f2626bcf26bda30575abee170321edc548a83296 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 04:47:58 +0900 Subject: [PATCH 06/23] test(attachments): align filename identity contract --- backend/tests/test_attachment_parser.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/tests/test_attachment_parser.py b/backend/tests/test_attachment_parser.py index e59484750..2de805b26 100644 --- a/backend/tests/test_attachment_parser.py +++ b/backend/tests/test_attachment_parser.py @@ -19,7 +19,7 @@ def test_html_attachment_preserves_parse_source_and_safe_display_text(): raw_content="

Launch

Ship

", ) - assert result.filename == "report.html" + assert result.filename == "attachment" assert result.content_type == "text/html" assert result.content == "Launch Ship" assert result.parse_content == "

Launch

Ship

" @@ -283,9 +283,11 @@ def test_safe_filename_strips_literal_path_segments_without_percent_decoding(): ) -def test_safe_filename_preserves_entity_decoded_percent_text(): - """HTML entities may normalize, but URL escapes stay literal filename text.""" - assert _safe_filename("%2e%2e%2fsecret.txt") == "%2e%2e%2fsecret.txt" +def test_safe_filename_preserves_entity_encoded_percent_text(): + """MIME filename character references remain literal filename text.""" + assert _safe_filename("%2e%2e%2fsecret.txt") == ( + "%2e%2e%2fsecret.txt" + ) def test_safe_filename_preserves_plain_percent_encoded_text(): From dd6976d97f55e7145f4ba9b8dd053974fb4e4404 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 05:42:59 +0900 Subject: [PATCH 07/23] test(attachments): reject unknown angle-bracket parser smuggling --- backend/tests/test_attachment_filename_identity.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/backend/tests/test_attachment_filename_identity.py b/backend/tests/test_attachment_filename_identity.py index 592695237..6ed1acd33 100644 --- a/backend/tests/test_attachment_filename_identity.py +++ b/backend/tests/test_attachment_filename_identity.py @@ -31,6 +31,20 @@ def test_html_markup_filename_cannot_smuggle_generic_mime_extension() -> None: 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=".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_benign_ampersand_filename_remains_literal() -> None: """Ordinary filename punctuation remains unchanged.""" assert _safe_filename("quarterly report & notes.pdf") == ( From 1261589e95be1ea9bf77360243c1aff7ed287bf7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 05:44:11 +0900 Subject: [PATCH 08/23] fix(attachments): reject raw angle-bracket filename authority --- backend/services/attachment_parser.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index de7458399..e3f47bd29 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -274,7 +274,11 @@ def _safe_filename(filename: str | None) -> str: segments, and preserve all other filename text exactly. """ display_filename = _sanitize_nul(filename or "attachment") - if contains_html_markup(display_filename): + if ( + "<" in display_filename + or ">" in display_filename + or contains_html_markup(display_filename) + ): return "attachment" display_filename = Path(display_filename.replace("\\", "/")).name.strip() if display_filename in {"", ".", ".."}: From 026a8e8111bec393032d02196bca7ac4e1b76439 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:42:17 +0900 Subject: [PATCH 09/23] test(attachments): reject whitespace-created parser authority --- backend/tests/test_attachment_filename_identity.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/backend/tests/test_attachment_filename_identity.py b/backend/tests/test_attachment_filename_identity.py index 6ed1acd33..469970c97 100644 --- a/backend/tests/test_attachment_filename_identity.py +++ b/backend/tests/test_attachment_filename_identity.py @@ -45,6 +45,20 @@ def test_unknown_angle_bracket_filename_cannot_select_generic_parser() -> None: assert result.parse_status == "unsupported_content_type" +def test_trailing_space_cannot_create_generic_mime_extension_authority() -> None: + """Trimming filename identity must not fabricate a parser-recognized suffix.""" + result = parse_email_attachment( + filename="quarterly.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_benign_ampersand_filename_remains_literal() -> None: """Ordinary filename punctuation remains unchanged.""" assert _safe_filename("quarterly report & notes.pdf") == ( From c32786ac078fcf4c4681e2afa72b5386376efdae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:43:08 +0900 Subject: [PATCH 10/23] fix(attachments): preserve trailing filename whitespace identity --- backend/services/attachment_parser.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index e3f47bd29..19ed2d9f7 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -268,10 +268,10 @@ def _safe_filename(filename: str | None) -> str: """Return a basename-only MIME filename without semantic re-decoding. MIME filename parameters reach this boundary as filename identity, not HTML - or URL text. Re-decoding percent escapes or character references can change - a literal suffix and therefore select a different parser for a generic MIME - type. Reject markup-looking names, remove only NULs and literal path - segments, and preserve all other filename text exactly. + or URL text. Re-decoding percent escapes, character references, or boundary + whitespace can change a literal suffix and therefore select a different + parser for a generic MIME type. Reject markup-looking names, remove only + NULs and literal path segments, and preserve all other filename text exactly. """ display_filename = _sanitize_nul(filename or "attachment") if ( @@ -280,8 +280,8 @@ def _safe_filename(filename: str | None) -> str: or contains_html_markup(display_filename) ): return "attachment" - display_filename = Path(display_filename.replace("\\", "/")).name.strip() - if display_filename in {"", ".", ".."}: + display_filename = Path(display_filename.replace("\\", "/")).name + if display_filename.strip() in {"", ".", ".."}: return "attachment" return display_filename From ab263a954709c8e726dbd5478f33097776a8eb2b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:45:08 +0900 Subject: [PATCH 11/23] revert(attachments): keep parser-boundary whitespace fix evidence realistic --- backend/services/attachment_parser.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index 19ed2d9f7..e3f47bd29 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -268,10 +268,10 @@ def _safe_filename(filename: str | None) -> str: """Return a basename-only MIME filename without semantic re-decoding. MIME filename parameters reach this boundary as filename identity, not HTML - or URL text. Re-decoding percent escapes, character references, or boundary - whitespace can change a literal suffix and therefore select a different - parser for a generic MIME type. Reject markup-looking names, remove only - NULs and literal path segments, and preserve all other filename text exactly. + or URL text. Re-decoding percent escapes or character references can change + a literal suffix and therefore select a different parser for a generic MIME + type. Reject markup-looking names, remove only NULs and literal path + segments, and preserve all other filename text exactly. """ display_filename = _sanitize_nul(filename or "attachment") if ( @@ -280,8 +280,8 @@ def _safe_filename(filename: str | None) -> str: or contains_html_markup(display_filename) ): return "attachment" - display_filename = Path(display_filename.replace("\\", "/")).name - if display_filename.strip() in {"", ".", ".."}: + display_filename = Path(display_filename.replace("\\", "/")).name.strip() + if display_filename in {"", ".", ".."}: return "attachment" return display_filename From 232f6448447e968df089cee21a68febfb903816a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:45:36 +0900 Subject: [PATCH 12/23] chore(tests): restore reachable MIME filename coverage --- backend/tests/test_attachment_filename_identity.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/backend/tests/test_attachment_filename_identity.py b/backend/tests/test_attachment_filename_identity.py index 469970c97..6ed1acd33 100644 --- a/backend/tests/test_attachment_filename_identity.py +++ b/backend/tests/test_attachment_filename_identity.py @@ -45,20 +45,6 @@ def test_unknown_angle_bracket_filename_cannot_select_generic_parser() -> None: assert result.parse_status == "unsupported_content_type" -def test_trailing_space_cannot_create_generic_mime_extension_authority() -> None: - """Trimming filename identity must not fabricate a parser-recognized suffix.""" - result = parse_email_attachment( - filename="quarterly.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_benign_ampersand_filename_remains_literal() -> None: """Ordinary filename punctuation remains unchanged.""" assert _safe_filename("quarterly report & notes.pdf") == ( From e68c017e40e705b4cca17c305acc547602b10850 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:47:15 +0900 Subject: [PATCH 13/23] test(attachments): reproduce NUL-created parser authority from EML --- .../test_attachment_filename_identity.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/backend/tests/test_attachment_filename_identity.py b/backend/tests/test_attachment_filename_identity.py index 6ed1acd33..c17945a98 100644 --- a/backend/tests/test_attachment_filename_identity.py +++ b/backend/tests/test_attachment_filename_identity.py @@ -1,6 +1,7 @@ """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: @@ -45,6 +46,35 @@ def test_unknown_angle_bracket_filename_cannot_select_generic_parser() -> None: 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: \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_benign_ampersand_filename_remains_literal() -> None: """Ordinary filename punctuation remains unchanged.""" assert _safe_filename("quarterly report & notes.pdf") == ( From f17846ed37d7ad15a97acadd1d74ee21659ddee7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 06:48:03 +0900 Subject: [PATCH 14/23] fix(attachments): reject NUL-bearing filename authority --- backend/services/attachment_parser.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index e3f47bd29..03b8b29ee 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -270,10 +270,13 @@ def _safe_filename(filename: str | None) -> str: MIME filename parameters reach this boundary as filename identity, not HTML or URL text. Re-decoding percent escapes or character references can change a literal suffix and therefore select a different parser for a generic MIME - type. Reject markup-looking names, remove only NULs and literal path + type. Reject NUL-bearing or markup-looking names, remove literal path segments, and preserve all other filename text exactly. """ - display_filename = _sanitize_nul(filename or "attachment") + raw_filename = filename or "attachment" + if "\x00" in raw_filename: + return "attachment" + display_filename = raw_filename if ( "<" in display_filename or ">" in display_filename From 40eb882e49eac1b7102794b45074a6d389dcea83 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 07:44:07 +0900 Subject: [PATCH 15/23] test(attachments): separate display filename from parser authority --- backend/tests/test_attachment_filename_identity.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/tests/test_attachment_filename_identity.py b/backend/tests/test_attachment_filename_identity.py index c17945a98..8f14cd6e4 100644 --- a/backend/tests/test_attachment_filename_identity.py +++ b/backend/tests/test_attachment_filename_identity.py @@ -18,15 +18,15 @@ def test_html_entity_dot_does_not_change_generic_mime_parser() -> None: assert result.parse_status == "unsupported_content_type" -def test_html_markup_filename_cannot_smuggle_generic_mime_extension() -> None: - """Markup-looking filename text fails closed instead of selecting a parser.""" +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="quarterly.json", content_type="application/octet-stream", raw_content=b'{"project":"Launch"}', ) - assert result.filename == "attachment" + 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" From 0e7df51292fab0efd4ad5e286f11991cdd7dbe11 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 07:44:33 +0900 Subject: [PATCH 16/23] test(attachments): preserve sanitized display filename contract --- backend/tests/test_attachment_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_attachment_parser.py b/backend/tests/test_attachment_parser.py index 2de805b26..f6da18971 100644 --- a/backend/tests/test_attachment_parser.py +++ b/backend/tests/test_attachment_parser.py @@ -19,7 +19,7 @@ def test_html_attachment_preserves_parse_source_and_safe_display_text(): raw_content="

Launch

Ship

", ) - assert result.filename == "attachment" + assert result.filename == "report.html" assert result.content_type == "text/html" assert result.content == "Launch Ship" assert result.parse_content == "

Launch

Ship

" From fea71c7fc2b49d7d47b0c91862786bccddd29d07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 07:45:10 +0900 Subject: [PATCH 17/23] fix(attachments): decouple display filename from parser authority --- backend/services/attachment_parser.py | 44 +++++++++++++++++++-------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index 03b8b29ee..8fbdada61 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -146,9 +146,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, ) @@ -265,30 +266,47 @@ def _parser_key_for(parse_content_type: str, parse_status: str) -> str: def _safe_filename(filename: str | None) -> str: - """Return a basename-only MIME filename without semantic re-decoding. + """Return a basename-only filename projection safe for display and storage. - MIME filename parameters reach this boundary as filename identity, not HTML - or URL text. Re-decoding percent escapes or character references can change - a literal suffix and therefore select a different parser for a generic MIME - type. Reject NUL-bearing or markup-looking names, remove literal path - segments, and preserve all other filename text exactly. + Display sanitization is intentionally separate from parser selection. Known + markup is stripped so active HTML cannot reach UI-facing attachment fields, + while unknown raw angle-bracket labels and NUL-bearing names fail closed. + Percent and character-reference text that is not markup remains literal. """ raw_filename = filename or "attachment" if "\x00" in raw_filename: return "attachment" - display_filename = raw_filename - if ( - "<" in display_filename - or ">" in display_filename - or contains_html_markup(display_filename) - ): + 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, NUL deletion, or whitespace trimming must never manufacture a + recognized suffix for a generic MIME type. + """ + raw_filename = filename or "attachment" + if "\x00" in 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): From 288136ef8b1a6ffd4c1d910fcd9654d25671f9d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 08:44:43 +0900 Subject: [PATCH 18/23] test(attachments): reject MIME filename control chars --- .../test_attachment_filename_identity.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/backend/tests/test_attachment_filename_identity.py b/backend/tests/test_attachment_filename_identity.py index 8f14cd6e4..4b16663a7 100644 --- a/backend/tests/test_attachment_filename_identity.py +++ b/backend/tests/test_attachment_filename_identity.py @@ -75,6 +75,53 @@ def test_nul_filename_cannot_create_generic_mime_extension_authority() -> None: 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: \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_benign_ampersand_filename_remains_literal() -> None: """Ordinary filename punctuation remains unchanged.""" assert _safe_filename("quarterly report & notes.pdf") == ( From 92ec6151d11d9b125880a0405dab9ef59bc9293a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 08:45:22 +0900 Subject: [PATCH 19/23] fix(attachments): fail closed on MIME filename controls --- backend/services/attachment_parser.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index 8fbdada61..8b0bfacfc 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -265,16 +265,24 @@ 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 C0, DEL, or C1 controls.""" + return any( + ord(character) < 0x20 or 0x7F <= ord(character) <= 0x9F + for character in filename + ) + + def _safe_filename(filename: str | None) -> str: """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 unknown raw angle-bracket labels and NUL-bearing names fail closed. + 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 "\x00" in raw_filename: + if _has_unsafe_filename_control(raw_filename): return "attachment" if contains_html_markup(raw_filename): display_filename = strip_html_markup(raw_filename) @@ -293,13 +301,17 @@ def _parser_authority_filename(filename: str | None) -> str: MIME filename identity is neither HTML nor URL source. Parser authority must therefore use the pre-display representation: semantic decoding, markup - stripping, NUL deletion, or whitespace trimming must never manufacture a - recognized suffix for a generic MIME type. + stripping, control deletion, or whitespace trimming must never manufacture + a recognized suffix for a generic MIME type. """ raw_filename = filename or "attachment" - if "\x00" in raw_filename: + if _has_unsafe_filename_control(raw_filename): return "attachment" - if "<" in raw_filename or ">" in raw_filename or contains_html_markup(raw_filename): + 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 {"", ".", ".."}: From 4a7b6ec4216bc74d5b944aef1c0e53fe5b504f9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 08:46:58 +0900 Subject: [PATCH 20/23] docs(attachments): trace MIME filename parser authority --- .../mime-attachment-filename-identity.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 docs/doctoring/mime-attachment-filename-identity.md diff --git a/docs/doctoring/mime-attachment-filename-identity.md b/docs/doctoring/mime-attachment-filename-identity.md new file mode 100644 index 000000000..cb2e449a4 --- /dev/null +++ b/docs/doctoring/mime-attachment-filename-identity.md @@ -0,0 +1,48 @@ +# MIME attachment filename identity and parser authority + +**Status:** open-PR evidence only; not yet shipped on protected `develop`. + +## Boundary under repair + +Naruon receives attachment filenames from Python's MIME parser and uses them for two different purposes: a human-facing display/storage filename and, only when the declared media type is generic, a filename-extension hint for parser selection. Those purposes must not share a representation-transform pipeline. + +The display/storage projection may remove known active markup and literal path segments. The parser-authority projection must not be derived from that sanitized display value, because percent decoding, entity decoding, markup stripping, control deletion, or whitespace normalization can manufacture a recognized suffix that the sender did not present to the parser boundary. + +The current PR therefore keeps the two projections separate. Generic-MIME extension fallback uses only the literal parser-authority projection. C0, DEL, and C1 control-bearing filenames fail closed to `attachment` for both display and parser selection; percent/entity text remains literal unless the MIME parser itself has already decoded a standards-defined MIME parameter encoding. + +## Standards traceability + +RFC 2183 defines the MIME `Content-Disposition` filename as a sender-suggested value, not trusted local authority. Section 2.3 requires a receiving MUA to check and possibly change the suggested filename so it conforms to local conventions and does not present a security problem, and says apparent directory path information should not be respected. Naruon therefore treats the value as a terminal component and rejects representations that are unsafe for display or parser authority. + +RFC 2231 extends MIME parameter values with character-set/language information and percent-encoded octets. That decoding belongs to the MIME parameter layer. With Python `policy.default`, `Message.get_filename()` already performs the RFC 2231 decoding before Naruon's attachment parser receives the filename. Naruon must not apply a second URL-style percent decode to that already-decoded identity. + +A production-ingress probe also shows why control characters need an explicit fail-closed rule. The RFC 2231 parameter `filename*=utf-8''quarterly%0A.json` is exposed by `Message.get_filename()` as `quarterly\n.json`. Without a control guard, `Path(...).suffix` still returns `.json`, allowing a generic `application/octet-stream` part to select the JSON parser while the display/storage value contains a line control. The regression contract fixes that exact MIME ingress rather than testing an artificial helper-only string. + +## TDD and implementation traceability + +- Protected base: `develop@042b0c70531b229af3acbd0421a2f23098d848b3`. +- Existing representation-separation causal head: `fea71c7fc2b49d7d47b0c91862786bccddd29d07`. +- Control-character RED: `288136ef8b1a6ffd4c1d910fcd9654d25671f9d1` adds RFC 2231 newline ingress plus C0/C1 unit cases. On the predecessor implementation, control-bearing values remain display filenames and a trailing `.json` retains generic-MIME parser authority. +- Causal fix: `92ec6151d11d9b125880a0405dab9ef59bc9293a` adds `_has_unsafe_filename_control()` and applies it before either display projection or parser-authority projection. +- Product code: `backend/services/attachment_parser.py`. +- Production-ingress regression: `backend/tests/test_attachment_filename_identity.py::test_rfc2231_control_character_filename_fails_closed`. +- Edge regression: `backend/tests/test_attachment_filename_identity.py::test_filename_controls_fail_closed_before_display_or_parser_selection`. + +No CardDAV/DAV/HTTP URL canonicalization, declared non-generic MIME type, attachment payload parser, PDF byte validation, or ordinary body-text sanitization is changed by this decision. + +## Verification required before merge + +The unchanged exact PR head must run the focused filename/parser suite and the normal Python 3.14 suite, then pass all repository-required security, dependency, coverage, image, SBOM/provenance, and review gates. The focused command is: + +```bash +cd backend +python -m pytest tests/test_attachment_filename_identity.py tests/test_attachment_parser.py -q +``` + +Queued, absent, startup-failed, predecessor-head, or author-only evidence is non-passing. The current organization Actions queue/startup failure is tracked through the canonical `.github` owner path rather than by weakening Naruon's required checks. + +## References (APA 7th) + +Freed, N., & Moore, K. (1997). *MIME parameter value and encoded word extensions: Character sets, languages, and continuations* (RFC 2231). RFC Editor. https://doi.org/10.17487/RFC2231 + +Troost, R., Dorner, S., & Moore, K. (1997). *Communicating presentation information in Internet messages: The Content-Disposition header field* (RFC 2183). RFC Editor. https://doi.org/10.17487/RFC2183 From 51f45492309b40799d7a026c52688a6a999a043e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 16:44:26 +0900 Subject: [PATCH 21/23] test(attachments): reject bidi control filename identity --- .../test_attachment_filename_identity.py | 74 ++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_attachment_filename_identity.py b/backend/tests/test_attachment_filename_identity.py index 4b16663a7..6803c79ae 100644 --- a/backend/tests/test_attachment_filename_identity.py +++ b/backend/tests/test_attachment_filename_identity.py @@ -122,8 +122,80 @@ def test_filename_controls_fail_closed_before_display_or_parser_selection() -> N 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: \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" - ) + ) \ No newline at end of file From ddc11593ac5af1528c9068f3275be728e28f3dc8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 16:45:32 +0900 Subject: [PATCH 22/23] fix(attachments): reject bidi control filename identity --- backend/services/attachment_parser.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/backend/services/attachment_parser.py b/backend/services/attachment_parser.py index 8b0bfacfc..de087087b 100644 --- a/backend/services/attachment_parser.py +++ b/backend/services/attachment_parser.py @@ -117,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) @@ -266,9 +275,11 @@ def _parser_key_for(parse_content_type: str, parse_status: str) -> str: def _has_unsafe_filename_control(filename: str) -> bool: - """Return whether a MIME filename contains C0, DEL, or C1 controls.""" + """Return whether a MIME filename contains unsafe control semantics.""" return any( - ord(character) < 0x20 or 0x7F <= ord(character) <= 0x9F + (codepoint := ord(character)) < 0x20 + or 0x7F <= codepoint <= 0x9F + or codepoint in _BIDI_FILENAME_CONTROL_CODEPOINTS for character in filename ) From 2eaf6134434a2ad29ad8fe0365aa1b34b848dd5f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 16:46:06 +0900 Subject: [PATCH 23/23] docs(attachments): trace bidi filename control boundary --- .../doctoring/mime-attachment-filename-identity.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/doctoring/mime-attachment-filename-identity.md b/docs/doctoring/mime-attachment-filename-identity.md index cb2e449a4..db9f3c1c2 100644 --- a/docs/doctoring/mime-attachment-filename-identity.md +++ b/docs/doctoring/mime-attachment-filename-identity.md @@ -8,7 +8,7 @@ Naruon receives attachment filenames from Python's MIME parser and uses them for The display/storage projection may remove known active markup and literal path segments. The parser-authority projection must not be derived from that sanitized display value, because percent decoding, entity decoding, markup stripping, control deletion, or whitespace normalization can manufacture a recognized suffix that the sender did not present to the parser boundary. -The current PR therefore keeps the two projections separate. Generic-MIME extension fallback uses only the literal parser-authority projection. C0, DEL, and C1 control-bearing filenames fail closed to `attachment` for both display and parser selection; percent/entity text remains literal unless the MIME parser itself has already decoded a standards-defined MIME parameter encoding. +The current PR therefore keeps the two projections separate. Generic-MIME extension fallback uses only the literal parser-authority projection. C0, DEL, C1, and Unicode `Bidi_Control` characters fail closed to `attachment` for both display and parser selection; percent/entity text remains literal unless the MIME parser itself has already decoded a standards-defined MIME parameter encoding. Ordinary Arabic/Hebrew and other right-to-left script text remains allowed: the restriction is on explicit/implicit directional formatting controls, not on bidirectional scripts. ## Standards traceability @@ -18,15 +18,19 @@ RFC 2231 extends MIME parameter values with character-set/language information a A production-ingress probe also shows why control characters need an explicit fail-closed rule. The RFC 2231 parameter `filename*=utf-8''quarterly%0A.json` is exposed by `Message.get_filename()` as `quarterly\n.json`. Without a control guard, `Path(...).suffix` still returns `.json`, allowing a generic `application/octet-stream` part to select the JSON parser while the display/storage value contains a line control. The regression contract fixes that exact MIME ingress rather than testing an artificial helper-only string. +Unicode Standard Annex #9 defines the `Bidi_Control` set used to influence display ordering: ALM, LRM, RLM, LRE, RLE, LRO, RLO, PDF, LRI, RLI, FSI, and PDI. UAX #9 explicitly distinguishes logical order from visual order and notes security concerns around bidirectional text; directional overrides are to be avoided where possible because of those concerns. A MIME attachment filename is both identity-bearing input and user-facing text, so retaining invisible directional controls permits a sender to make the visible filename diverge from its logical code-point order without changing the stored string. The RFC 2231 production regression `filename*=utf-8''quarterly%E2%80%AEfdp.json` proves that U+202E RIGHT-TO-LEFT OVERRIDE reaches Naruon's filename boundary after standards-defined MIME decoding. Naruon therefore rejects the Unicode `Bidi_Control` property at this trust boundary while preserving ordinary RTL-script filenames that contain no directional formatting control. + ## TDD and implementation traceability - Protected base: `develop@042b0c70531b229af3acbd0421a2f23098d848b3`. - Existing representation-separation causal head: `fea71c7fc2b49d7d47b0c91862786bccddd29d07`. - Control-character RED: `288136ef8b1a6ffd4c1d910fcd9654d25671f9d1` adds RFC 2231 newline ingress plus C0/C1 unit cases. On the predecessor implementation, control-bearing values remain display filenames and a trailing `.json` retains generic-MIME parser authority. -- Causal fix: `92ec6151d11d9b125880a0405dab9ef59bc9293a` adds `_has_unsafe_filename_control()` and applies it before either display projection or parser-authority projection. +- Control-character causal fix: `92ec6151d11d9b125880a0405dab9ef59bc9293a` adds `_has_unsafe_filename_control()` and applies it before either display projection or parser-authority projection. +- Bidi-control RED: `51f45492309b40799d7a026c52688a6a999a043e` adds a real RFC 2231 U+202E ingress regression plus all twelve Unicode `Bidi_Control` code-point cases. The predecessor helper rejects only C0/DEL/C1, so it retains the controls and generic `.json` parser authority. +- Bidi-control causal fix: `ddc11593ac5af1528c9068f3275be728e28f3dc8` adds the UAX #9 `Bidi_Control` code-point set to the same fail-closed boundary. The regression also proves that an Arabic filename without formatting controls remains valid and continues to select the JSON parser under generic MIME. - Product code: `backend/services/attachment_parser.py`. -- Production-ingress regression: `backend/tests/test_attachment_filename_identity.py::test_rfc2231_control_character_filename_fails_closed`. -- Edge regression: `backend/tests/test_attachment_filename_identity.py::test_filename_controls_fail_closed_before_display_or_parser_selection`. +- Production-ingress regressions: `backend/tests/test_attachment_filename_identity.py::test_rfc2231_control_character_filename_fails_closed` and `::test_rfc2231_bidi_control_filename_fails_closed`. +- Edge regressions: `backend/tests/test_attachment_filename_identity.py::test_filename_controls_fail_closed_before_display_or_parser_selection` and `::test_bidi_controls_fail_closed_without_rejecting_bidi_scripts`. No CardDAV/DAV/HTTP URL canonicalization, declared non-generic MIME type, attachment payload parser, PDF byte validation, or ordinary body-text sanitization is changed by this decision. @@ -45,4 +49,6 @@ Queued, absent, startup-failed, predecessor-head, or author-only evidence is non Freed, N., & Moore, K. (1997). *MIME parameter value and encoded word extensions: Character sets, languages, and continuations* (RFC 2231). RFC Editor. https://doi.org/10.17487/RFC2231 +The Unicode Consortium. (2025). *Unicode Bidirectional Algorithm* (Unicode Standard Annex #9, Version 17.0.0, Revision 51). https://www.unicode.org/reports/tr9/ + Troost, R., Dorner, S., & Moore, K. (1997). *Communicating presentation information in Internet messages: The Content-Disposition header field* (RFC 2183). RFC Editor. https://doi.org/10.17487/RFC2183