Skip to content

id(directory_mail): OutboxSink writes To/Subject into the .eml header block with none of SmtpSink's validation — a newline in either forges arbitrary headers #2404

Closed

Description

@dezren39

Problem

pkgs/id/src/directory_mail.rs defines three MailSinks for the same Mail { to, subject, body }, but only one of them validates the header fields:

  • SmtpSink::send (directory_mail.rs:148ff) refuses a non-ASCII or non-plain recipient (plain_address, :148-160), refuses a sender that is not a plain address at construction (:130-143), and refuses a subject containing any control character: "the subject has a line break or control character" (:161-164). The module's own test at :453 sends a smuggled a@b.co>\r\nRCPT TO:<x@y.z recipient and expects refusal.
  • OutboxSink::send (directory_mail.rs:58-76) performs no validation at all and interpolates both fields straight into the header block of the .eml file it writes:
write!(file, "To: {}\nSubject: {}\n\n{}", mail.to, mail.subject, mail.body)

A subject (or to) containing \n therefore forges arbitrary extra headers in the stored message — Bcc:, a second Content-Type:, or a whole second message after a blank line — in a file whose entire purpose is to be read/picked up as email. Mail's fields are pub (directory_mail.rs:23-31) and the sinks are the crate's public mail API, so the invariant the SMTP path enforces is silently absent for any present or future caller of the file sink (today's only in-tree caller, code_mail in directory_view.rs:1466, happens to pass a normalized address and a fixed subject — the sink itself guarantees nothing).

Evidence (verified this run, main 5a34fa70)

Laptop probe test added to the verbatim directory_mail.rs tests in a laptop worktree (file md5 58718d786ae90d6ea7cb65688cbc20e2, identical on sandbox main and the laptop; worktree removed after the run):

let sink = OutboxSink::new(dir.clone());
let mail = Mail {
    to: "victim@example.com".into(),
    subject: "Your code\nBcc: attacker@evil.example".into(),
    body: "hello".into(),
};
sink.send(&mail).unwrap();
// read back the single .eml file written
assert!(found.contains("\nBcc: attacker@evil.example"));

Result: test directory_mail::tests::probe_outbox_writes_injected_header ... ok — the .eml on disk contains a real forged Bcc: header line. The same subject is refused by SmtpSink::send before any network I/O (control-character check), so the two sinks disagree on what a legal Mail is.

Existing tests cover the outbox happy path only (text.starts_with("To: a@b.co\nSubject: Your code\n\n"), :290); no test feeds a control character to OutboxSink.

Why it matters

The outbox directory is a trust boundary: files dropped there are mail. A header-forgery primitive in one sink but not its sibling is exactly the kind of inconsistency that gets exploited when a new caller (a world mail action with a user-chosen subject, a different confirmation flow) reuses the public sink trusting the module's established invariant. The fix is small and the invariant already exists in the same file — it should live in one place (on Mail construction or shared at the top of every send) instead of per-sink.

Acceptance criteria

Activity

  1. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    task/triageQueue packet kind: verify, reproduce, and route this item
    on Oct 29, 2026
  2. developing-today commented on Oct 29, 2026

    @developing-today
    Contributor
  3. added
    source/creatorFiled by the new-issue-creator job
    priority/lowNice to have; do when convenient
    queue/queuedHas a queued task in the Supabase agent queue
    app/idPackage/app: id (pkgs/ or apps/ in this repo)
    effort/xsTrivial: expected under 30 minutes of work
    on Oct 29, 2026
  4. dezren39 commented on Oct 29, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

    • Agent: new-issue-creator (lane B, new-issue-creator-code-b)
    • Session / ID: cron job new-issue-creator-code-b, run 2026-10-29T18:20Z
    • Found via: filed this run
    • Task: Triage the missing header-field validation in OutboxSink::send (pkgs/id/src/directory_mail.rs:58-76) — it interpolates to/subject into the .eml header block unchecked while SmtpSink in the same file refuses control characters and non-plain addresses; pin whether validation is per-sink or shared on Mail, then route a small_fix packet.
    • Expectation: effort/xs — done when the validation home is pinned and a fix packet with the no-file-created regression test is routed.
    • Links: evidence in issue body; laptop probe test in a disposable laptop worktree (directory_mail.rs md5 58718d786ae90d6ea7cb65688cbc20e2, identical to main 5a34fa7): OutboxSink wrote a forged Bcc: header line from a newline-bearing subject, probe test passed, worktree removed.
  5. added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 1, 2026
  6. 11 remaining items

  7. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

  8. dezren39 commented on Nov 3, 2026

    @dezren39
    MemberAuthor

    agent-note:v1
    state: triaged (coverage pass) — fix already written, shepherd enqueued
    Agent: queue worker (repo-worker-code), OpenChamber scheduled lane 02cafe76
    Found via: queue claim (task 7916, triage)

    Verdict: still live and already fixed in an open PR; the gap is that nobody is shepherding it. Follow-up enqueued as task 8166.

  9. developing-today commented on Nov 3, 2026

    @developing-today
    Contributor

    agent-note:v1
    state: triaged (coverage pass)
    Coverage pass 2026-11-03: reviewed in the fleet-wide per-issue pass (drift / overlap / already-handled / stale / blockers / ordering). Verdict: still live; queued in repo-health-code-pilot as triage at priority 30 (priority/low). If this is already handled at current main, the triage closes it with evidence; if it needs splitting, the triage files the split.
    Found via: coverage pass 2026-11-03

  10. added a commit that references this issue on Nov 3, 2026
  11. dezren39 commented on Nov 3, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

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

Metadata

Metadata

Assignees

Labels

app/idPackage/app: id (pkgs/ or apps/ in this repo)effort/xsTrivial: expected under 30 minutes of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/lowNice to have; do when convenientsource/creatorFiled by the new-issue-creator jobtask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions