Reject unsafe command arguments and cleartext AUTH
Two things the client got wrong, both of which put something on the wire
that the caller did not ask for.
`mailFrom`, `rcptTo`, `mailFromUtf8` and `hello` interpolated their
argument straight into the command line, so an address carrying CR or LF
ended the line early and everything after it was read by the server as
further SMTP commands -- `bob@example.net>\r\nRCPT TO:<victim@example.net`
delivered to two people. Those four now check the argument first and
return `error.UnsafeArgument` rather than send it, as does AUTH PLAIN,
where the byte that matters is NUL: it separates the three fields, so one
hidden inside a field moves the boundary and authenticates as somebody
else. The check is `protocol.isSafeArgument`, and it is deliberately
framing only -- CR, LF and NUL and nothing else -- because the RFC 5321
path grammar rejects addresses that real deployments carry every day, and
a client that refused them would be the wrong tool.
`authenticate` preferred AUTH PLAIN unconditionally, which sent the
password in the clear whenever the transport was. The client cannot tell
on its own -- it is handed a reader and a writer and has no idea what is
under them -- so it now assumes the worst and takes the answer from the
caller: `setTransport` records it for a STARTTLS upgrade, and a session
that speaks TLS from the first byte sets `security` itself. PLAIN and
LOGIN return `error.InsecureTransport` on a plaintext transport, and
`authenticate` inverts its preference there to CRAM-MD5, the one
mechanism of the three that never puts the password on the wire.
`allow_cleartext_auth` is the way past that for a connection protected by
something this library cannot see -- a unix socket, an SSH tunnel, a
loopback test -- and `zsmtp send --allow-cleartext-auth` exposes it.
The interop test grew the case that matters: the same delivery to exim
fails without the opt-in and succeeds over STARTTLS without one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
Reject unsafe command arguments and cleartext AUTH
Two things the client got wrong, both of which put something on the wire
that the caller did not ask for.
`mailFrom`, `rcptTo`, `mailFromUtf8` and `hello` interpolated their
argument straight into the command line, so an address carrying CR or LF
ended the line early and everything after it was read by the server as
further SMTP commands -- `bob@example.net>\r\nRCPT TO:<victim@example.net`
delivered to two people. Those four now check the argument first and
return `error.UnsafeArgument` rather than send it, as does AUTH PLAIN,
where the byte that matters is NUL: it separates the three fields, so one
hidden inside a field moves the boundary and authenticates as somebody
else. The check is `protocol.isSafeArgument`, and it is deliberately
framing only -- CR, LF and NUL and nothing else -- because the RFC 5321
path grammar rejects addresses that real deployments carry every day, and
a client that refused them would be the wrong tool.
`authenticate` preferred AUTH PLAIN unconditionally, which sent the
password in the clear whenever the transport was. The client cannot tell
on its own -- it is handed a reader and a writer and has no idea what is
under them -- so it now assumes the worst and takes the answer from the
caller: `setTransport` records it for a STARTTLS upgrade, and a session
that speaks TLS from the first byte sets `security` itself. PLAIN and
LOGIN return `error.InsecureTransport` on a plaintext transport, and
`authenticate` inverts its preference there to CRAM-MD5, the one
mechanism of the three that never puts the password on the wire.
`allow_cleartext_auth` is the way past that for a connection protected by
something this library cannot see -- a unix socket, an SSH tunnel, a
loopback test -- and `zsmtp send --allow-cleartext-auth` exposes it.
The interop test grew the case that matters: the same delivery to exim
fails without the opt-in and succeeds over STARTTLS without one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
Reject unsafe command arguments and cleartext AUTH
Two things the client got wrong, both of which put something on the wire
that the caller did not ask for.
`mailFrom`, `rcptTo`, `mailFromUtf8` and `hello` interpolated their
argument straight into the command line, so an address carrying CR or LF
ended the line early and everything after it was read by the server as
further SMTP commands -- `bob@example.net>\r\nRCPT TO:<victim@example.net`
delivered to two people. Those four now check the argument first and
return `error.UnsafeArgument` rather than send it, as does AUTH PLAIN,
where the byte that matters is NUL: it separates the three fields, so one
hidden inside a field moves the boundary and authenticates as somebody
else. The check is `protocol.isSafeArgument`, and it is deliberately
framing only -- CR, LF and NUL and nothing else -- because the RFC 5321
path grammar rejects addresses that real deployments carry every day, and
a client that refused them would be the wrong tool.
`authenticate` preferred AUTH PLAIN unconditionally, which sent the
password in the clear whenever the transport was. The client cannot tell
on its own -- it is handed a reader and a writer and has no idea what is
under them -- so it now assumes the worst and takes the answer from the
caller: `setTransport` records it for a STARTTLS upgrade, and a session
that speaks TLS from the first byte sets `security` itself. PLAIN and
LOGIN return `error.InsecureTransport` on a plaintext transport, and
`authenticate` inverts its preference there to CRAM-MD5, the one
mechanism of the three that never puts the password on the wire.
`allow_cleartext_auth` is the way past that for a connection protected by
something this library cannot see -- a unix socket, an SSH tunnel, a
loopback test -- and `zsmtp send --allow-cleartext-auth` exposes it.
The interop test grew the case that matters: the same delivery to exim
fails without the opt-in and succeeds over STARTTLS without one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
RFC review: standards list, doc links, exim-derived protocol gauntlet
README gains a Standards section listing every implemented RFC with
its per-side coverage (5321, 1870, 6152, 2920, 3207, 8314, 4954, 4616,
2195, draft-murchison-sasl-login, 3463/2034, 6531, and 8446 via
tls.zig). Doc comments now link each RFC mention to the datatracker,
with section fragments where a section is cited; authLogin's doc notes
it has no RFC.
The review surfaced two fixes: the server always emitted RFC 3463
enhanced status codes but never advertised ENHANCEDSTATUSCODES
(RFC 2034) - now it does; and root.zig's module doc still called TLS
an eventual feature.
Also adds a protocol gauntlet unit test distilled from exim's test
suite (test/scripts/0000-Basic, notably 0019's syntax-error dialogue
and the 0008/0100 dotted message lines), asserting the exact 28-reply
transcript and resulting envelope. The dialogue was first validated by
running exim's own scriptable test client (test/src/client.c, built
with zig cc) against zsmtp serve.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012HBHFhoTYa8TU9GLwobfbx