[Sprint 7] Security Hardening + Critical Fixes#9
Conversation
- S7.1: Enforce DKIM signing on all outbound email. send::run() and MCP email_send/email_reply now return errors when DKIM key is unavailable instead of silently sending unsigned messages. - S7.2: Sanitize email headers against CRLF injection. Added sanitize_header_value() and validate_header_value() to strip \r\n from From/To/Subject before header interpolation, preventing header injection attacks via MCP tool calls. - S7.3: Atomic file ID generation in ingest. Replaced check-then-act generate_file_id() + fs::write() with create_file_atomic() using OpenOptions::create_new(true) to prevent TOCTOU race conditions in concurrent mail delivery. - S7.4: Fix verify race condition. Moved "before" file snapshot before send::run() call so fast replies aren't missed. Added error handling for missing catchall mailbox directory. - S7.5: Integrate end-to-end verify into setup. Added VerifyRunner trait and prompt after DNS verification to optionally run aimx verify. Verify failure is non-blocking (DNS may still be propagating).
uzyn
left a comment
There was a problem hiding this comment.
Sprint 7 Code Review: Security Hardening + Critical Fixes
Sprint Goal Assessment
Sprint 7 aims to fix all critical and high-severity issues from the post-v1 code review audit, covering security vulnerabilities (DKIM enforcement, header injection), data loss risks (atomic file IDs), race conditions (verify timing), and PRD compliance (verify in setup). All five stories are implemented and the overall approach is solid.
Acceptance Criteria Checklist
S7.1 -- DKIM Enforcement
| Criterion | Status |
|---|---|
send::run() returns error when DKIM config/key missing |
MET |
MCP email_send returns clear MCP error when DKIM key unavailable |
MET |
MCP email_reply returns clear MCP error when DKIM key unavailable |
MET |
Unit test: send::run() with missing DKIM key returns error |
MET |
| Unit test: MCP send/reply with missing DKIM key returns error response | MET (load_dkim_key_missing_returns_error covers the shared helper; no separate MCP-level send/reply integration test, but acceptable since both paths call the same load_dkim_key) |
S7.2 -- CRLF Header Injection
| Criterion | Status |
|---|---|
| From/To/Subject sanitized to strip CRLF | MET |
Sanitization covers both \r\n and bare \n |
MET |
compose_message() returns error if header contains CRLF after sanitization (defense in depth) |
NOT MET -- see Bug #1 below |
Unit test: subject with \r\n no injected headers |
MET |
Unit test: from/to with \r\n no injected headers |
MET |
| Unit test: normal headers pass unchanged | MET |
S7.3 -- Atomic File ID Generation
| Criterion | Status |
|---|---|
File creation uses OpenOptions::create_new(true) |
MET |
| On collision, ID counter increments and retries | MET |
| Unit test: two rapid calls produce different IDs | MET |
| Unit test: pre-existing file triggers retry, not overwrite | MET |
S7.4 -- Verify Race Condition
| Criterion | Status |
|---|---|
| "Before" snapshot taken before sending test email | MET |
| Existing unit tests still pass | MET |
| Missing catchall directory produces clear error | MET |
S7.5 -- Verify in Setup
| Criterion | Status |
|---|---|
| After DNS passes, setup offers end-to-end verify | MET |
| Verify failure is non-blocking | MET |
| Verify success reports full success | MET |
| User can decline verify | MET |
| Unit test: setup flow includes verify step (mockable) | PARTIAL -- mock trait exists and is tested, but no test exercises run_setup_with_verify with the mock (stdin blocks). The setup_verify_step_available test only calls runner.run_verify(None) directly, which is identical to verify_runner_trait_mock_pass. |
Bugs Found
Bug #1 (Non-blocker): validate_header_value is dead code after sanitization
In src/send.rs lines 77-83, write_common_headers first calls sanitize_header_value() which strips all \r and \n characters, then calls validate_header_value() on the already-sanitized values. Since sanitization removes all CR/LF, the validation check can never trigger -- it is dead code.
The S7.2 AC explicitly states: "compose_message() returns an error if a header value contains CRLF after sanitization (defense in depth)". The current implementation satisfies the "defense" idea (injection is prevented by sanitization), but the "in depth" layer (validation returning an error) is unreachable.
Recommendation: Either (a) validate first, then sanitize as a fallback, or (b) remove validate_header_value since it creates a false sense of defense-in-depth. Option (a) would be:
validate_header_value("From", &args.from)?;
let from = sanitize_header_value(&args.from);This way, direct callers get an error, and the sanitization still protects as a safety net. Alternatively, just remove the dead validation since sanitization alone is sufficient.
Bug #2 (Non-blocker): In-Reply-To and References headers are not sanitized
In src/send.rs lines 91-98, the In-Reply-To and References headers are interpolated without CRLF sanitization:
msg.push_str(&format!("In-Reply-To: {reply_id}\r\n"));
...
msg.push_str(&format!("References: {refs}\r\n"));These values come from args.reply_to and args.references, which in the MCP email_reply path originate from stored email frontmatter (meta.message_id, meta.references). A malicious inbound email could contain a Message-ID header with CRLF sequences, which would be stored verbatim in frontmatter and then injected into outbound headers when an agent replies.
Recommendation: Apply sanitize_header_value() to reply_id and refs before interpolation.
Test Coverage Gaps
-
S7.5 integration test gap: The
setup_verify_step_availabletest is essentially a duplicate ofverify_runner_trait_mock_pass-- it calls the mock directly rather than testing the verify step withinrun_setup_with_verify. The comment acknowledges this ("We can't fully test run_setup_with_verify here because it reads stdin"). Consider providing a stdin mock or extracting the verify prompt logic into a testable function. -
No counter overflow guard in
create_file_atomic: Thecounter(u32) increments without an upper bound. While extremely unlikely in practice (would need ~4 billion files in one day), a safety check likeif counter > 999_999 { return Err(...) }would be prudent. -
No test for CRLF in reply_to/references: Given the injection risk in Bug #2, tests for CRLF in
reply_toandreferencesfields would be valuable.
Security Issues
-
CRLF injection in In-Reply-To/References (Bug #2 above) -- Medium severity. A crafted inbound email could cause header injection in outbound replies.
-
send_with_transportstill acceptsOption<dkim_key>-- Low severity. The function signature still allowsNonefor the DKIM key, meaning future callers could accidentally bypass DKIM. All current callers enforce it upstream, so this is a design note rather than a bug.
Alignment with PRD
- FR-18 (DKIM signing): Now properly enforced -- unsigned sends are rejected. Aligned.
- FR-8 (End-to-end verify in setup): Now integrated as an optional step. Aligned.
- NFR-6 (Secure defaults): DKIM enforcement improves this. CRLF sanitization is a good addition. The In-Reply-To/References gap slightly weakens it.
Summary
The sprint delivers all five stories with solid implementations. The atomic file creation (S7.3), verify race fix (S7.4), and DKIM enforcement (S7.1) are clean and correct. The CRLF sanitization (S7.2) effectively prevents the primary attack vector but has two gaps: dead validation code and unsanitized In-Reply-To/References headers.
Verdict: Needs minor fixes
The two bugs are non-blocking for the security goals (the primary injection vectors in From/To/Subject are properly handled), but Bug #2 (unsanitized In-Reply-To/References) is a genuine injection vector that should be addressed before merge given this is a security hardening sprint. Bug #1 (dead validation) is a code quality issue.
…robustness - Reorder validate-before-sanitize in write_common_headers so compose_message() returns an error when From/To/Subject contain CRLF, with sanitization as a safety net (Bug #1) - Sanitize In-Reply-To and References headers to prevent CRLF injection from malicious inbound Message-ID values (Bug #2) - Add upper-bound guard (999,999) on counter in create_file_atomic to prevent theoretical u32 overflow (Suggestion #4) - Replace duplicate setup_verify_step_available test with a compile-time signature check that is distinct from verify_runner_trait_mock_pass (Suggestion #3) - Update CRLF injection tests to verify error return instead of silent sanitization, add new tests for reply_to/references injection
Review Feedback — Changes and ResponsesFixed
Intentionally Left As-Is
Test ResultsAll 237 unit tests and 35 integration tests pass. Formatter is clean. |
uzyn
left a comment
There was a problem hiding this comment.
Re-review: Sprint 7 — Security Hardening + Critical Fixes
All five previously flagged issues have been satisfactorily addressed. APPROVED (posted as comment since GitHub prevents self-approval).
Issue-by-Issue Verification
-
Bug #1 (Dead
validate_header_valuecode) — RESOLVED. Validation now runs before sanitization inwrite_common_headers(lines 77-79 validate raw values, lines 81-83 sanitize as safety net). All four CRLF injection tests now assert error returns instead of silent sanitization. The defense-in-depth layering is correct: validate → error on bad input, sanitize → safety net. -
Bug #2 (Unsanitized In-Reply-To/References) — RESOLVED.
sanitize_header_value()is applied to bothreply_to(line 92) andreferences(line 95) before interpolation into headers. Two new tests (reply_to_crlf_injection_sanitized,references_crlf_injection_sanitized) confirm no header injection occurs through these fields. -
Suggestion #3 (S7.5 test duplication) — RESOLVED.
setup_verify_step_availableis now a compile-time function pointer check that verifiesrun_setup_with_verifyaccepts the correct signature including&dyn VerifyRunner. This is clearly distinct fromverify_runner_trait_mock_passwhich tests mock behavior. -
Suggestion #4 (No upper-bound guard on counter) — RESOLVED.
MAX_COUNTERof 999,999 added tocreate_file_atomicwith a descriptive error message when exhausted. -
Suggestion #5 (
send_with_transportaccepts Option for DKIM key) — ACCEPTED AS-IS. The implementer's reasoning is sound:send_with_transportis the low-level transport layer, and DKIM enforcement is the caller's responsibility. Bothrun()and the MCP handlers now properly enforce DKIM (returning errors on missing keys), so all production paths are covered. KeepingOptionat the transport layer maintains separation of concerns and enables clean testing.
Recommended merge commit message
[Sprint 7] Security hardening and critical fixes
- S7.1: Enforce DKIM signing on all outbound email paths (send::run,
MCP email_send, MCP email_reply) — missing DKIM key now returns an
error instead of silently sending unsigned messages
- S7.2: Prevent CRLF header injection in From/To/Subject (validate
then sanitize) and In-Reply-To/References (sanitize)
- S7.3: Atomic file ID generation using OpenOptions::create_new to
eliminate TOCTOU race in concurrent mail delivery
- S7.4: Fix verify race condition by snapshotting mailbox before
sending test email; add missing-catchall-dir guard
- S7.5: Integrate optional end-to-end verify into setup wizard via
VerifyRunner trait
LGTM — approving.
…lans Code-review-backed fix plans for each of the 10 findings, with file:line refs, effort estimates, and a priority order. No code changes yet — this consolidates the investigation so fixes can be sequenced. - #10 DKIM mismatch: not a code bug; DNS republish + optional startup check - #9 shell injection: pass trigger vars via env, not string substitution - #8 MCP writes: route state mutations through daemon UDS - #7 claude-code hint: print `claude mcp add` command post-install - #4 send config read: move mailbox resolution to daemon side - #2 SPF: plumb envelope MAIL FROM from smtp session through ingest - #5 wildcard send: remove wildcard branch from resolve_from_mailbox - #1 mailbox create: add restart hint (or route via daemon) - #3 plan wording: clarify "compose new" in docs/manual-test.md
* docs: add manual test results with findings Full execution of docs/manual-test.md against agent.zeroshot.lol. 10 findings recorded with severity and fix direction, notably: - P0 DKIM key on disk does not match DNS TXT (root cause of outbound dkim=fail at Gmail) - P0 Shell injection in on_receive cmd template expansion - P1 MCP write ops (email_mark_read, etc.) fail when MCP runs as non-root due to root:root 0644 mailbox files * docs: add Recommended fixes section with per-finding implementation plans Code-review-backed fix plans for each of the 10 findings, with file:line refs, effort estimates, and a priority order. No code changes yet — this consolidates the investigation so fixes can be sequenced. - #10 DKIM mismatch: not a code bug; DNS republish + optional startup check - #9 shell injection: pass trigger vars via env, not string substitution - #8 MCP writes: route state mutations through daemon UDS - #7 claude-code hint: print `claude mcp add` command post-install - #4 send config read: move mailbox resolution to daemon side - #2 SPF: plumb envelope MAIL FROM from smtp session through ingest - #5 wildcard send: remove wildcard branch from resolve_from_mailbox - #1 mailbox create: add restart hint (or route via daemon) - #3 plan wording: clarify "compose new" in docs/manual-test.md
Sprint 44 (post-launch security + quick fixes) addresses findings #9, #10, #7, #1-tier-1, #3. Sprint 45 (strict outbound + MCP writes via daemon addresses #4, #5, #8 and introduces UDS MARK-READ/MARK-UNREAD verbs. Sprint 46 (mailbox CRUD via UDS) addresses #1-tier-2 with MAILBOX-CREATE/MAILBOX-DELETE verbs so daemon picks up changes live. PRD FR-18d tightened: outbound send now requires a concrete non-wildcard mailbox under config.domain; catchall is inbound-only. PRD FR-18e added to cover the new state-mutation verbs on the UDS socket. Finding #2 (SPF envelope MAIL FROM) excluded — already shipped in cd22428. EOF )
[Sprint 7] Security hardening and critical fixes - S7.1: Enforce DKIM signing on all outbound email paths (send::run, MCP email_send, MCP email_reply) — missing DKIM key now returns an error instead of silently sending unsigned messages - S7.2: Prevent CRLF header injection in From/To/Subject (validate then sanitize) and In-Reply-To/References (sanitize) - S7.3: Atomic file ID generation using OpenOptions::create_new to eliminate TOCTOU race in concurrent mail delivery - S7.4: Fix verify race condition by snapshotting mailbox before sending test email; add missing-catchall-dir guard - S7.5: Integrate optional end-to-end verify into setup wizard via VerifyRunner trait
…lans Code-review-backed fix plans for each of the 10 findings, with file:line refs, effort estimates, and a priority order. No code changes yet — this consolidates the investigation so fixes can be sequenced. - #10 DKIM mismatch: not a code bug; DNS republish + optional startup check - #9 shell injection: pass trigger vars via env, not string substitution - #8 MCP writes: route state mutations through daemon UDS - #7 claude-code hint: print `claude mcp add` command post-install - #4 send config read: move mailbox resolution to daemon side - #2 SPF: plumb envelope MAIL FROM from smtp session through ingest - #5 wildcard send: remove wildcard branch from resolve_from_mailbox - #1 mailbox create: add restart hint (or route via daemon) - #3 plan wording: clarify "compose new" in docs/manual-test.md
* docs: add manual test results with findings Full execution of docs/manual-test.md against agent.zeroshot.lol. 10 findings recorded with severity and fix direction, notably: - P0 DKIM key on disk does not match DNS TXT (root cause of outbound dkim=fail at Gmail) - P0 Shell injection in on_receive cmd template expansion - P1 MCP write ops (email_mark_read, etc.) fail when MCP runs as non-root due to root:root 0644 mailbox files * docs: add Recommended fixes section with per-finding implementation plans Code-review-backed fix plans for each of the 10 findings, with file:line refs, effort estimates, and a priority order. No code changes yet — this consolidates the investigation so fixes can be sequenced. - #10 DKIM mismatch: not a code bug; DNS republish + optional startup check - #9 shell injection: pass trigger vars via env, not string substitution - #8 MCP writes: route state mutations through daemon UDS - #7 claude-code hint: print `claude mcp add` command post-install - #4 send config read: move mailbox resolution to daemon side - #2 SPF: plumb envelope MAIL FROM from smtp session through ingest - #5 wildcard send: remove wildcard branch from resolve_from_mailbox - #1 mailbox create: add restart hint (or route via daemon) - #3 plan wording: clarify "compose new" in docs/manual-test.md
Sprint 44 (post-launch security + quick fixes) addresses findings #9, #10, #7, #1-tier-1, #3. Sprint 45 (strict outbound + MCP writes via daemon addresses #4, #5, #8 and introduces UDS MARK-READ/MARK-UNREAD verbs. Sprint 46 (mailbox CRUD via UDS) addresses #1-tier-2 with MAILBOX-CREATE/MAILBOX-DELETE verbs so daemon picks up changes live. PRD FR-18d tightened: outbound send now requires a concrete non-wildcard mailbox under config.domain; catchall is inbound-only. PRD FR-18e added to cover the new state-mutation verbs on the UDS socket. Finding #2 (SPF envelope MAIL FROM) excluded — already shipped in f5cebd2. EOF )
Summary
send::run()and MCPemail_send/email_replynow error when DKIM key is missing instead of silently sending unsigned emailsanitize_header_value()to strip\r\nfrom From/To/Subject headers, preventing header injection attacksgenerate_file_id()+fs::write()withcreate_file_atomic()usingO_CREAT|O_EXCLsend::run()so fast replies aren't missed; added missing catchall dir errorVerifyRunnertrait and interactive prompt after DNS verification to optionally run end-to-end email verificationTest plan
cargo clippy -- -D warningscleancargo fmt -- --checkcleanrun_with_missing_dkim_key_returns_error,run_with_missing_config_returns_error,load_dkim_key_missing_returns_error,load_dkim_key_present_returns_oksubject_crlf_injection_produces_no_extra_headers,from_crlf_injection_produces_no_extra_headers,to_crlf_injection_produces_no_extra_headers,bare_newline_injection_produces_no_extra_headers,normal_headers_pass_unchangedatomic_file_creation_retries_on_collision,atomic_file_creation_two_rapid_calls_produce_different_idsrun_errors_on_missing_catchall_dirverify_runner_trait_mock_pass,verify_runner_trait_mock_fail,setup_verify_step_available