Skip to content

[Sprint 7] Security Hardening + Critical Fixes#9

Merged
uzyn merged 2 commits into
mainfrom
sprint-7-security-hardening
Apr 10, 2026
Merged

[Sprint 7] Security Hardening + Critical Fixes#9
uzyn merged 2 commits into
mainfrom
sprint-7-security-hardening

Conversation

@uzyn

@uzyn uzyn commented Apr 10, 2026

Copy link
Copy Markdown
Owner

Summary

  • S7.1 - DKIM Enforcement: send::run() and MCP email_send/email_reply now error when DKIM key is missing instead of silently sending unsigned email
  • S7.2 - CRLF Header Injection: Added sanitize_header_value() to strip \r\n from From/To/Subject headers, preventing header injection attacks
  • S7.3 - Atomic File ID: Replaced TOCTOU-vulnerable generate_file_id() + fs::write() with create_file_atomic() using O_CREAT|O_EXCL
  • S7.4 - Verify Race Condition: Moved "before" file snapshot before send::run() so fast replies aren't missed; added missing catchall dir error
  • S7.5 - Verify in Setup: Added VerifyRunner trait and interactive prompt after DNS verification to optionally run end-to-end email verification

Test plan

  • 235 unit tests pass (15 new tests added)
  • 35 integration tests pass (no regressions)
  • cargo clippy -- -D warnings clean
  • cargo fmt -- --check clean
  • S7.1: run_with_missing_dkim_key_returns_error, run_with_missing_config_returns_error, load_dkim_key_missing_returns_error, load_dkim_key_present_returns_ok
  • S7.2: subject_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_unchanged
  • S7.3: atomic_file_creation_retries_on_collision, atomic_file_creation_two_rapid_calls_produce_different_ids
  • S7.4: run_errors_on_missing_catchall_dir
  • S7.5: verify_runner_trait_mock_pass, verify_runner_trait_mock_fail, setup_verify_step_available

- 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 uzyn left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. S7.5 integration test gap: The setup_verify_step_available test is essentially a duplicate of verify_runner_trait_mock_pass -- it calls the mock directly rather than testing the verify step within run_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.

  2. No counter overflow guard in create_file_atomic: The counter (u32) increments without an upper bound. While extremely unlikely in practice (would need ~4 billion files in one day), a safety check like if counter > 999_999 { return Err(...) } would be prudent.

  3. No test for CRLF in reply_to/references: Given the injection risk in Bug #2, tests for CRLF in reply_to and references fields would be valuable.

Security Issues

  1. CRLF injection in In-Reply-To/References (Bug #2 above) -- Medium severity. A crafted inbound email could cause header injection in outbound replies.

  2. send_with_transport still accepts Option<dkim_key> -- Low severity. The function signature still allows None for 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
@uzyn

uzyn commented Apr 10, 2026

Copy link
Copy Markdown
Owner Author

Review Feedback — Changes and Responses

Fixed

  1. Bug [Sprint 1] Core Pipeline + Idea Validation #1: Dead validate_header_value code — Reordered to validate before sanitize in write_common_headers. compose_message() now returns an error when From/To/Subject contain CRLF characters (satisfying the S7.2 AC for defense-in-depth). Sanitization remains as a safety net after validation. Updated all four CRLF injection tests to assert error returns instead of silent sanitization.

  2. Bug [Sprint 2] DKIM + Production-Quality Outbound #2: Unsanitized In-Reply-To/References headers — Applied sanitize_header_value() to both reply_to and references values before interpolation into headers. Added two new tests (reply_to_crlf_injection_sanitized, references_crlf_injection_sanitized) that verify CRLF in these fields does not produce injected header lines.

  3. Suggestion [Sprint 2.5] Non-blocking Cleanup #3: S7.5 test duplication — Replaced setup_verify_step_available with a compile-time function pointer check that verifies run_setup_with_verify accepts a VerifyRunner trait object. This is now distinct from verify_runner_trait_mock_pass (which tests the mock behavior).

  4. Suggestion [Sprint 3] MCP Server #4: No upper-bound guard on counter — Added a MAX_COUNTER of 999,999 to create_file_atomic. If exhausted, returns a descriptive error instead of silently wrapping.

Intentionally Left As-Is

  1. Suggestion [Sprint 4] Channel Manager + Inbound Trust #5: send_with_transport still accepts Option for DKIM key — This is a deliberate design choice. The function is the low-level transport layer; DKIM enforcement is the caller's responsibility (and run() enforces it). Keeping Option allows the function to be used in tests without DKIM setup and maintains separation of concerns. All production code paths already enforce DKIM via run() or the MCP handlers.

Test Results

All 237 unit tests and 35 integration tests pass. Formatter is clean.

@uzyn uzyn left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Bug #1 (Dead validate_header_value code) — RESOLVED. Validation now runs before sanitization in write_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.

  2. Bug #2 (Unsanitized In-Reply-To/References) — RESOLVED. sanitize_header_value() is applied to both reply_to (line 92) and references (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.

  3. Suggestion #3 (S7.5 test duplication) — RESOLVED. setup_verify_step_available is now a compile-time function pointer check that verifies run_setup_with_verify accepts the correct signature including &dyn VerifyRunner. This is clearly distinct from verify_runner_trait_mock_pass which tests mock behavior.

  4. Suggestion #4 (No upper-bound guard on counter) — RESOLVED. MAX_COUNTER of 999,999 added to create_file_atomic with a descriptive error message when exhausted.

  5. Suggestion #5 (send_with_transport accepts Option for DKIM key) — ACCEPTED AS-IS. The implementer's reasoning is sound: send_with_transport is the low-level transport layer, and DKIM enforcement is the caller's responsibility. Both run() and the MCP handlers now properly enforce DKIM (returning errors on missing keys), so all production paths are covered. Keeping Option at 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.

@uzyn uzyn merged commit 1adb4da into main Apr 10, 2026
2 checks passed
@uzyn uzyn deleted the sprint-7-security-hardening branch April 10, 2026 01:34
uzyn added a commit that referenced this pull request Apr 17, 2026
…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
uzyn added a commit that referenced this pull request Apr 17, 2026
* 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
uzyn added a commit that referenced this pull request Apr 17, 2026
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
)
uzyn added a commit that referenced this pull request Apr 21, 2026
[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
uzyn added a commit that referenced this pull request Apr 21, 2026
…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
uzyn added a commit that referenced this pull request Apr 21, 2026
* 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
uzyn added a commit that referenced this pull request Apr 21, 2026
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
)
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