fix(attachments): close the .txt gap in the parseability gate

Review follow-ups on the attachment gate.

.txt was listed as parser-backed, but it has no parser — it *is* the
plain-text fallthrough. That let it skip the content check, so renaming a
video to notes.txt walked straight back into the bug the gate exists for
(verified: 5132 chars of binary "extracted" and stored). The list is now
exactly the file extractor's keys, .txt included in the content check like
any other unparsed suffix, and the drift test asserts equality rather than
containment. The sniff now recognises a UTF-16/32 BOM as text, so a
Notepad "Unicode" .txt is not caught by the NUL-byte rule.

The picker's accept filter listed parser-backed suffixes only, hiding .txt,
.py and .log — files the gate reads happily — behind "All files". It now
carries text/* as well, so it can never be narrower than what the upload
accepts.

A rejected batch carries one errors entry per file, but the non-200 branch
applied the top-level message to every chip, so two files failing for two
reasons both reported the first one. Reasons are now matched by
upload_index, with the top-level message as fallback.

_get_store_attachment_user_error no longer reads str(exc): the
unsupported-type message is rebuilt from the filename, so no exception
state can reach a response body (CodeQL py/stack-trace-exposure).
This commit is contained in:
Alex committed 2026-09-03 09:13:24 +01:00
1 parent 6d0319fb63
commit f7cd94668f
9 files changed
+302 -49

No files matched your search

+37
View File
@@ -2369,6 +2369,20 @@ class TestGetStoreAttachmentUserError:
msg = _get_store_attachment_user_error(RuntimeError("oops"))
assert msg == "Failed to process file"
@pytest.mark.unit
def test_unsupported_type_message_is_rebuilt_from_the_filename(self):
"""Nothing is read off the exception, so its text cannot reach a response."""
from application.api.user.attachments.routes import (
_get_store_attachment_user_error,
)
from application.upload_limits import UnsupportedUploadTypeError
err = UnsupportedUploadTypeError("internals: /srv/app/tmp/staged-42")
msg = _get_store_attachment_user_error(err, "clip.mp4")
assert msg == "Unsupported file type: .mp4"
assert "/srv/app" not in msg
class TestRequireLiveSttRedisUnavailable:
"""Cover lines 99-102: Redis unavailable returns 503."""
@@ -2421,6 +2435,29 @@ class TestStoreAttachmentTypeGate:
mock_storage.save_file.assert_not_called()
mock_store_attachment.assert_not_called()
@patch("application.api.user.tasks.store_attachment.delay")
def test_rejects_a_binary_renamed_to_a_text_suffix(
self, mock_store_attachment, flask_app
):
""".txt has no parser, so it is judged on content like any other suffix."""
from application.api.user.attachments.routes import StoreAttachment
app = Flask(__name__)
mock_storage = MagicMock()
with patch("application.api.user.base.storage", mock_storage), app.test_request_context(
"/api/store_attachment",
method="POST",
data={"file": (io.BytesIO(MP4_BYTES), "notes.txt")},
content_type="multipart/form-data",
):
request.decoded_token = {"sub": "test_user"}
response = StoreAttachment().post()
assert _get_response_status(response) == 400
assert _get_response_json(response)["message"] == "Unsupported file type: .txt"
mock_storage.save_file.assert_not_called()
mock_store_attachment.assert_not_called()
@patch("application.api.user.tasks.store_attachment.delay")
def test_accepts_a_text_file_with_no_dedicated_parser(
self, mock_store_attachment, flask_app