Commit graph

4 commits

Author SHA1 Message Date
6432a1f5ff audit-fix round 2: LOW-3, LOW-5, LOW-6
- LOW-3 (canonical Flag display): render_flag() pattern-matches the
  async-imap Flag enum to its IMAP wire syntax — \\Seen, \\Flagged,
  \\Deleted, etc. — instead of Debug syntax ("Seen", Custom("...")).
  Consumers checking for \\Seen now match.
- LOW-5 (schema 0=default sentinel): limit fields are now Option<u32>
  instead of bare u32 with a 0-means-default contract. JSON schema
  output is clearer; clamp_limit() still treats Some(0) the same as
  None for backwards compatibility.
- LOW-6 (config chmod gate): Config::load() now refuses to read a
  config file with group/other read bits set. Same posture as
  ssh-keygen rejecting loose private-key permissions. Refuses 0644
  cleanly; accepts 0600. Unix-only — Windows path is a no-op.

Smoke verified: loose-chmod test refuses to start with the expected
error; tight-chmod test starts and serves initialize cleanly. All
seven tools still listed with valid input schemas.
2026-05-21 07:55:43 -07:00
f4b3199e86 audit-fix sprint: 12 findings from the max-effort adversarial pass
Threats closed (CRIT/HIGH):

- CRIT-1 (mail_move folder injection via uid_copy fallback):
  validate_mailbox() rejects CR/LF/NUL/"/\\ on every folder arg
  (list/read/search/thread/move). async-imap's uid_copy doesn't quote
  the destination — quoting metacharacters would have smuggled COPY
  targets. We refuse the characters outright rather than escape.

- HIGH-1 (mail_thread message_id backslash bypass): seed Message-ID
  rejection set extended from {", CR, LF} to {", \\, CR, LF, {}.
  A bare \\ inside the IMAP quoted-string would escape the closing
  quote and confuse the server's parser. { also opener of literal-form.

- CRIT-2 / HIGH (search literal-form): mail_search now rejects the
  IMAP {N} literal-form opener via has_imap_literal(). CR/LF were
  already blocked.

- HIGH-3 (strip_quotes asymmetric strip): only strips matching pairs.
  A password starting with " but lacking a closing " no longer
  silently loses its leading char.

- HIGH-4 (no attachment size cap): new MAX_ATTACHMENT_BYTES (25 MB
  decoded, matches Gmail), MAX_ATTACHMENTS (25), MAX_BODY_BYTES
  (5 MB on body + body_html), MAX_TOTAL_RECIPIENTS (100). Pre-decode
  bound on encoded base64 length prevents giant-payload OOM before
  the decode buffer allocates.

- HIGH-5 (raw_eml fetch unbounded): RFC822.SIZE pre-flight on
  mail_inbox_read refuses messages > MAX_RAW_EML_BYTES (20 MB) before
  the body transfer.

- HIGH-6 (flat headers map empties for structured variants):
  switched from h.value().as_text() (which returns None for Address /
  DateTime / ContentType / Received) to Message::header_raw(name)
  which returns the un-decoded header value as &str uniformly across
  all variants. Date / From / To / Subject / Content-Type /
  DKIM-Signature etc. all populate correctly now.

- HIGH-9 (password resolved after TLS handshake): resolve_password()
  now runs at the top of open_session(), before TCP connect, so a
  missing/unreadable credential errors before the IMAP server logs
  an unauthenticated session that fail2ban could pattern on.

MED/LOW:

- MED-8 mail_search tool description: clarifies that CR/LF + {N}
  literal-form are rejected but the query is otherwise raw — caller
  must not pass untrusted input.

- MED-10 ServerHandler instructions: lists all 7 tools (not just the
  original 3) and explains UID stability + BODY.PEEK posture.

- LOW-2 snippet_unused dead code: deleted.

Smoke verified 2026-05-21:
- send -> land -> read round trip clean
- headers map now shows Date / From / To / Subject / Message-ID /
  User-Agent / Content-Type / DKIM-Signature populated
- 4 injection probes all cleanly rejected: CR in folder, {5}hello
  search literal, message_id with \\, folder with "
- mail_move INBOX <-> Junk round trip clean

Findings explicitly verified NOT-exploitable by the audit (no code
change needed): lettre CR/LF filter on Subject/Message-Id, lettre
mailbox rfc2822 parser, MIME-boundary randomness, rustls hostname
verification, password leakage in error paths, MIME smuggling via
filename, format_imap_since negative-year bypass.

Deferred (separate follow-ups): session pool (MED-6), partial-body
fetch in mail_inbox_read (MED-9), canonical Flag display rendering
(LOW-3), JSON schema 0=default sentinel (LOW-5), config chmod check
(LOW-6), proper unit/integration test suite (INFO-3).
2026-05-21 07:38:43 -07:00
4251f514e6 Phase B + folders: mail_folder_list, mail_search, mail_thread, mail_move
Phase B per the spec (multi-account already supported via the
account arg) + full folder support:

- mail_folder_list  : enumerate IMAP folders. Returns {name, delimiter,
                      attributes, selectable}. selectable=false flags
                      \Noselect mailboxes (parent-of-children only).
- mail_search       : raw IMAP SEARCH passthrough against any folder.
                      ALL/OR/NOT combinators supported. CR/LF in the
                      query rejected (anti-injection).
- mail_thread       : seed Message-ID -> matches the seed itself plus
                      any message whose References or In-Reply-To
                      contains the seed. Oldest-first ordering (root
                      -> leaves). Brackets on the seed are optional.
- mail_move         : UID MOVE (RFC 6851) with COPY + STORE +FLAGS
                      \Deleted + UID EXPUNGE fallback. Destination
                      folder must already exist.

Refactor: shared fetch_summaries() helper used by search + thread.

Smoke verified 2026-05-21 against kayos@sulkta.com:
- 6 folders listed (DMARC, Drafts, INBOX, Junk, Sent, Trash)
- SEARCH "SUBJECT \"mail-mcp smoke\"" finds uid 27
- THREAD on uid 27's Message-ID returns 1 msg (the seed)
- MOVE INBOX(27) -> Junk(2) -> INBOX(28) round trip clean

Build gotcha: async-imap's uid_store and uid_expunge return non-Unpin
streams (unlike uid_fetch). Pinned via Box::pin inside scoped blocks.
2026-05-21 07:26:44 -07:00
2240bf745e mail-mcp v0.1 — Rust MCP server for Sulkta email
Phase A: mail_send + mail_inbox_list + mail_inbox_read. Replaces
scripts/kayos_mail.py with a typed MCP server. Outbound guarantees Date,
Message-ID (own-domain), User-Agent, MIME-Version, multipart/alternative
for HTML+text, multipart/mixed for attachments, In-Reply-To +
References for threading.

Single account in v0.1 (default_account from config). Phase B adds
multi-account + threading + search; Phase C adds mark + attachments +
reply helper.

Stack: rmcp 0.1 (matches aldabra), lettre 0.11 + tokio-rustls, async-imap
0.10, mail-parser 0.9. Stderr-only logging (stdout is the MCP transport).

Smoke verified 2026-05-21: send -> land -> read kayos@sulkta.com round
trip, DKIM-Signature + Authentication-Results pass at the rspamd relay.
2026-05-21 06:50:25 -07:00