Skip to content

security: download-path confinement, CRLF rejection, resource limits, HTTP-transport warning - #201

Closed
lucagattoni wants to merge 6 commits into
Wh1isper:mainfrom
lucagattoni:20260713_1519-safety-hardening
Closed

security: download-path confinement, CRLF rejection, resource limits, HTTP-transport warning#201
lucagattoni wants to merge 6 commits into
Wh1isper:mainfrom
lucagattoni:20260713_1519-safety-hardening

Conversation

@lucagattoni

Copy link
Copy Markdown
Contributor

What this delivers

Five security-hardening fixes to the email tools, plus the docs that describe them. Independent of the permission-scopes PR (#200) at the code level — branched off current main.

Fixes

  1. download_attachment path confinement (dd47c6e) — save paths are confined to attachment_download_dir (default ~/Downloads). A save_path escaping it via .. or an absolute path is rejected, so attacker-controlled attachment bytes can't be written over ~/.ssh/authorized_keys, a crontab, or the config file. New attachment_download_dir setting + MCP_EMAIL_SERVER_ATTACHMENT_DOWNLOAD_DIR.
  2. CRLF injection rejection (e5fd74e) — reject CR/LF in recipients, header values, and attachment paths. Closes SMTP-envelope injection via bcc (passed raw to aiosmtplib, never header-serialized).
  3. HTTP transport unauthenticated warning (e718855) — prominent docs warning that sse/streamable-http have no auth; Host/Origin validation is anti-DNS-rebinding, not access control. A non-loopback bind publishes an unauthenticated email gateway.
  4. Resource-exhaustion ceilings (40efcd1) — MAX_PAGE_SIZE=500, MAX_EMAIL_IDS_PER_CALL=100, and attachment-size bounds guard against self-DoS from unbounded tool inputs.
  5. Final-component symlink escape fix (3d0a70a) — _resolve_download_path now realpaths the full candidate (final component included), so a symlink planted at the target path can't redirect the write outside root. Plus adversarial-review gap closures.

Test plan

  • uv run pytest -q559 passed (new: test_download_path_confinement.py, test_crlf_injection.py, test_resource_limits.py)
  • uv run ruff check / ruff format --check — clean
  • prettier --check README.md — clean
  • Verified each guard is wired to its call site (not just defined)

Note on overlap with #200

The safety code is fully independent of #200. The docs overlap: #200 moves the HTTP-transport section from README into docs/installation.md, while this PR edits that section in README (its current home). Whichever merges second will need a trivial docs rebase — the code merges cleanly either way.

Opened as draft for review of the reconciliation (permission-scope enforcement calls were dropped from the cherry-picks since they belong to #200).

🤖 Generated with Claude Code

https://claude.ai/code/session_013pd8meRq4jSiqaLq715nSD

lucagattoni and others added 6 commits July 13, 2026 15:23
download_attachment wrote attacker-controlled bytes to an arbitrary
save_path (Path(save_path).write_bytes with parent mkdir -p), gated only
by the enable_attachment_download flag — a prompt-injected agent could
overwrite ~/.ssh/authorized_keys, crontabs, or the config file once the
feature was enabled.

Add attachment_download_dir (env: MCP_EMAIL_SERVER_ATTACHMENT_DOWNLOAD_DIR,
default ~/Downloads). save_path is now resolved and must stay within that
root; the parent is canonicalised with realpath first, so a symlink
component pointing outside the root is rejected too. Relative paths
resolve under the root.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GHu48yxodNvMb8vsuU5NSu
Header injection was already blocked by the stdlib email serializer, but
bcc values go straight to the SMTP envelope (aiosmtplib writes RCPT TO
raw, no CRLF check — verified against 4.0.2), so a newline in a recipient
could inject extra SMTP commands/recipients. Reject CR/LF at the tool
boundary for recipients/cc/bcc, the reply/threading header values, and
attachment paths, in both send_email and save_to_mailbox, before dispatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GHu48yxodNvMb8vsuU5NSu
- list_emails_metadata: page >= 1, page_size in [1, 500] (was unbounded)
- get_emails_content: email_ids capped at 1..100 per call (was unbounded)
- attachments: 25 MiB cap per file on both send (stat before read) and
  download (checked before write), preventing multi-GB reads into memory

Constraints are enforced by the FastMCP validation layer and advertised
in each tool's input schema. Extracted the attachment-search loop from
download_attachment into a helper to keep it under the complexity limit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GHu48yxodNvMb8vsuU5NSu
Final adversarial review found a real bypass of the download_attachment
confinement: _resolve_download_path canonicalised only the parent, so a
symlink planted AT the target path (final component) inside the download
root redirected the write outside it (verified PoC wrote bytes to a
sibling dir). The existing symlink test only covered an intermediate
component, giving false confidence.

- Canonicalise the FULL candidate with realpath so a final-component
  symlink is resolved and caught; reject a path that names the root dir
  or has no filename (avoids a later IsADirectoryError).
- Handler write now uses O_NOFOLLOW + owner-only mode, refusing a symlink
  swapped into the final component after the check (TOCTOU defence).
- Correct the MAX_ATTACHMENT_BYTES docstring: on download it bounds the
  disk write, not the in-memory fetch.
- Add an aggregate outbound cap (count + total bytes, stat'd before read).
- CRLF-check subject too (clean error instead of relying on the stdlib
  HeaderParseError).

Regression tests added for the final-component symlink escape, empty/dir
save_path, subject CRLF, and the aggregate caps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GHu48yxodNvMb8vsuU5NSu
Ports the safety-hardening doc changes (originally authored against
docs/installation.md, which lands in the permission-scopes PR) onto the
current README structure: a prominent unauthenticated-gateway warning on
the HTTP transport section, the 0.0.0.0 Compose caution, the download-path
confinement note, and the MCP_EMAIL_SERVER_ATTACHMENT_DOWNLOAD_DIR row.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013pd8meRq4jSiqaLq715nSD
@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.40506% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
mcp_email_server/emails/classic.py 86.6% 4 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@lucagattoni

Copy link
Copy Markdown
Contributor Author

Superseded by #210, which rebases these security fixes cleanly onto current main (post-#204 tooling and #205 docs reorganization) and harmonizes the docs into the new multi-file docs/ structure. Closing in favor of that PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant