harden(mail): address PR #935 round-4 review

No blockers raised; hardening + clarity:
- client/mail.py: URL-encode the caller-supplied attachment_id
  (quote(..., safe="")) — defense-in-depth against path traversal.
- server/mail.py: measure attachment content in UTF-8 bytes (not characters)
  for the size cap and the sentinel message.
- scanner.py: bound _mail_cap_logged (insertion-ordered dict + oldest-first
  eviction at 50k, mirroring _consent_backstop_done) so the cap-log dedup set
  can't leak in a long-running multi-tenant process; reword the cap log to not
  imply MAIL_SCAN_MAX_PER_MAILBOX is operator-tunable (it's the Mail OCS max).
- models/mail.py: comment why GetAttachmentResponse doesn't nest MailAttachment
  (different OCS endpoint shape).
- mail_content.py: document format_mail_addresses' empty-entry skip contract.

_potentially_deleted doc_type-in-key remains tracked as Deck #376 (pre-existing
cross-cutting; reviewer confirmed deferral).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-20 13:12:15 +02:00
co-authored by Claude Opus 4.8
parent 891d07db12
commit 0856d59956
5 changed files with 54 additions and 17 deletions
+7 -1
View File
@@ -14,6 +14,7 @@ OCS API controllers (Mail 5.x / Nextcloud 32+).
import logging
from typing import Any
from urllib.parse import quote
from httpx import HTTPStatusError, RequestError, Response
@@ -188,5 +189,10 @@ class MailClient(BaseNextcloudClient):
Returns:
Attachment object with name, mime, size, content.
"""
data = await self._ocs_get(f"/message/{message_id}/attachment/{attachment_id}")
# URL-encode the caller-supplied attachment id (defense-in-depth: keeps
# a value like "../.." from being normalised into a different path).
safe_attachment_id = quote(attachment_id, safe="")
data = await self._ocs_get(
f"/message/{message_id}/attachment/{safe_attachment_id}"
)
return data or {}