mailer: a booker's address is validated at intake, and never written into a header unparsed - #45
Open
distronode-com wants to merge 1 commit into
Open
mailer: a booker's address is validated at intake, and never written into a header unparsed#45distronode-com wants to merge 1 commit into
distronode-com wants to merge 1 commit into
Conversation
…into a header unparsed
The To: header is the one header field assembled from caller-supplied input
with nothing encoding it. buildRaw parsed each recipient with mail.ParseAddress
and, when that failed, appended the raw string anyway, so a CR/LF inside an
address would have closed the To: line and started a header of the sender's
choosing. Every other field was already safe: Subject and the From display name
go through mime.QEncoding, which renders CR and LF as =0D and =0A; attachment
filenames are written with %q; bodies sit below the blank line inside
random-boundary MIME parts.
The value reaching it is public and unauthenticated. POST /v1/bookings takes
`email` straight from the request body, checks only that it is non-empty, and
carries it to mailer.Message.To through dispatchBookingConfirmation. The
conversational booking assistant's `book` tool is the same hole with a looser
source: the address is whatever the model extracted from booker chat. Neither
path called mail.ParseAddress anywhere.
It is not exploitable as it stands, and the reason is worth stating precisely
rather than relying on. Send issues c.Rcpt(to) for every recipient before it
opens DATA, and net/smtp runs validateLine inside Rcpt, which refuses any
string containing CR or LF. A CRLF-bearing address therefore aborts the
exchange at RCPT TO and never reaches the body. That is incidental protection:
it lives one call away in the standard library, it is not a property of this
file, and it does nothing for a future transport that reuses buildRaw to
assemble a message. The guarantee should be local to where the header is built.
Two layers, because the two answer different questions.
Intake decides what is stored. normalizeBookerEmail trims, parses with
mail.ParseAddress, and returns a.Address — the bare address, not the input.
CreateBooking calls it immediately after the emptiness check, before anything
reads req.Email, and answers 400 "email must be a valid email address" in the
same {"error": …} shape as its neighbours. createBookingForSlug — the shared
core behind the assistant's `book` tool and the MCP create_booking tool — calls
it before it persists anything; the REST handler does not route through that
core, which is why both are touched. mail.ParseAddress is the whole rule: no
length or domain heuristics, since the property that matters is parseability,
not plausibility.
The mailer decides what can be written. buildRaw returns ([]byte, error) and
refuses an address it cannot parse, and an empty recipient list, with
ErrInvalidRecipient. Send already built the message before dialing, so the
refusal costs no connection — a test drives Send at a listener that counts
accepts and asserts zero. The rejected value is deliberately kept out of the
error, which is logged and may carry CR/LF; the error names the index instead.
Storing a.Address rather than the input is a small deliberate behaviour change:
a pasted `Bob <bob@example.com>` is now recorded as bob@example.com. That is
what the rest of the system already assumes it holds — the hourly per-email
throttle, the per-invitee active-booking cap and the To: header all compare or
emit the stored value as a plain address.
resend.go, the HTTPS transport, posts msg.To as JSON and so has no header to
inject into. It does accept unparsed recipients and is left alone; its callers
now come through the same validated intake.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes One security hardening commit: booker email is validated at intake, and SMTP no longer writes an unparsed recipient into To:.
- Intake normalisation —
normalizeBookerEmail(ParseAddress→ barea.Address) on RESTCreateBooking(before throttle/Stripe) and oncreateBookingForSlug(assistant + MCP); assistant gets a dedicated retry hint. - SMTP
buildRaw— returns([]byte, error); empty/unparseable recipients yieldErrInvalidRecipient(index only) before dial; existing subject/From encoding tests updated viamustBuildRaw. - Coverage —
buildRawrecipient matrix, Send-never-dials listener test, REST reject/normalise/ordinary-email cases; CHANGELOG Security note.
Grok | 𝕏
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

CodeQL flags
internal/mailer/smtp.go'swc.Write(raw)as email-content injection. The weak point it is pointing at is inbuildRaw: theTo:header is assembled frommsg.To, and an address thatmail.ParseAddressrejects was appended to the header verbatim. Every other header input is already safe (the subject and the From display name are Q-encoded, attachment filenames are%q-quoted, bodies sit below the blank line inside random-boundary parts); the recipient list was the one raw path, and on the public booking path it is the booker's own input with nothing but an emptiness check in front of it.It is not exploitable today, and this PR does not claim otherwise:
SendcallsRcptbeforeDATA, andnet/smtp'svalidateLinerefuses any CR or LF there, so a CRLF-bearing address aborts atRCPT TO. That protection is incidental, lives in the standard library one call away, and covers only this transport. This change makes the guarantee local, in two layers:normalizeBookerEmail(trim,mail.ParseAddress, keepa.Address) runs at both places a booker's address enters:CreateBookingforPOST /v1/bookings, before the hourly throttle and the Stripe session read it, answering 400email must be a valid email address; and the top ofcreateBookingForSlug, which the assistant'sbooktool and MCPcreate_bookingshare, so an LLM-extracted address gets the same rule and the assistant gets a retry hint instead of the generic failure. Small deliberate behaviour change:"Bob" <bob@example.com>is stored asbob@example.com, which is what the per-invitee cap, the throttle and the manage-link lookups already assume they hold.buildRawreturns([]byte, error)and refuses an unparseable or empty recipient list withErrInvalidRecipient(index, never the value, since the error is logged).Sendreturns it before anything is dialed.Tests cover the plain, display-name, two-recipient, CRLF, unparseable and empty cases for
buildRaw; aSendthat must never dial (listener counting accepts); and the three intake shapes onPOST /v1/bookings, including that nothing is persisted on a refusal. The existing subject-encoding test is untouched.CHANGELOG.mdgets a line under Unreleased → Security.Noted and left alone: the invite path (admin-authenticated) does not
ParseAddresseither; the Resend transport accepts unparsed recipients but posts JSON, so there is no header to inject into; the 400 sentence is English while the throttle errors beside it are translated, and adding a key touches all eight locale files.🤖 Generated with Claude Code