An SMTP client and server library for Zig implementing RFC 5321.
0

Configure Feed

Select the types of activity you want to include in your feed.

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

+293 -35
+32 -5
README.md
··· 57 57 the server's actual code and text. `mailFrom`/`rcptTo`/`sendMessage` are also 58 58 available individually. 59 59 60 + Addresses and the EHLO domain are checked before they are written: a value 61 + containing CR, LF or NUL is rejected with `error.UnsafeArgument` rather than 62 + sent, since it would otherwise end the command line early and let the rest of 63 + it be read as further SMTP commands. The check is `protocol.isSafeArgument`, 64 + and it is framing only — it does not claim the address is a well-formed 65 + mailbox. 66 + 60 67 Message bodies can also be streamed instead of passed as a slice — from any 61 68 reader via `sendMessageReader(&reader)`, or push-style via `data()`, which 62 69 returns a writer that dot-stuffs and normalizes line endings as content ··· 77 84 ### Authentication 78 85 79 86 `hello` reports the server's advertised mechanisms in `extensions.auth`; 80 - `authenticate` picks the best one (PLAIN, then LOGIN, then CRAM-MD5), or use 81 - `authPlain`/`authLogin`/`authCramMd5` directly. PLAIN and LOGIN send 82 - credentials unprotected, so use TLS on real networks. A 535 rejection 83 - surfaces as `error.AuthenticationFailed` with the reply in `last_reply`. 87 + `authenticate` picks the best one, or use `authPlain`/`authLogin`/ 88 + `authCramMd5` directly. A 535 rejection surfaces as 89 + `error.AuthenticationFailed` with the reply in `last_reply`. 84 90 85 91 ```zig 86 92 const extensions = try client.hello("my-host.example.com"); 87 93 try client.authenticate(extensions, "user", "password"); 88 94 ``` 95 + 96 + PLAIN and LOGIN send the password in the clear — base64 is not encryption — 97 + so the client refuses them unless `client.security` is `.encrypted`, 98 + returning `error.InsecureTransport` instead. The library is handed a reader 99 + and a writer and cannot see what is underneath them, so it assumes the worst: 100 + `setTransport` records the answer for a STARTTLS upgrade, and a session 101 + speaking TLS from the first byte sets `client.security = .encrypted` itself. 102 + Which mechanism `authenticate` picks follows from that — PLAIN, then LOGIN, 103 + then CRAM-MD5 once encrypted, and CRAM-MD5 first when it is not, since that 104 + is the one mechanism of the three that never puts the password on the wire. 105 + 106 + For a connection protected by something the library cannot see — a unix 107 + socket, an SSH tunnel, a loopback test — `client.allow_cleartext_auth = true` 108 + permits the cleartext mechanisms without claiming the transport is encrypted. 89 109 90 110 ### TLS 91 111 ··· 107 127 }); 108 128 defer tls.deinit(gpa); 109 129 var client: zsmtp.Client = .init(tls.reader(), tls.writer(), &reply_buf); 130 + client.security = .encrypted; // the transport is TLS; `init` cannot tell 110 131 // ... greet, hello, sendMail ... 111 132 try client.quit(); 112 133 try tls.end(); // close_notify, before closing the socket ··· 122 143 try tls.init(io, gpa, &stream_reader.interface, &stream_writer.interface, .{ 123 144 .host = "smtp.example.com", 124 145 }); 125 - client.setTransport(tls.reader(), tls.writer()); 146 + client.setTransport(tls.reader(), tls.writer(), .encrypted); 126 147 _ = try client.hello("my-host.example.com"); // server state was reset 127 148 ``` 128 149 ··· 206 227 # Same, over implicit TLS or STARTTLS (--insecure skips cert verification): 207 228 zsmtp send --tls smtp.example.com 465 me@example.com you@example.net 208 229 zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net 230 + 231 + # Authenticate. Over a plaintext connection this refuses PLAIN and LOGIN 232 + # rather than put the password on the wire; --allow-cleartext-auth overrides 233 + # that for a connection protected by other means: 234 + zsmtp send --starttls --user me --password secret smtp.example.com 587 \ 235 + me@example.com you@example.net 209 236 ``` 210 237 211 238 ## Status
+22 -3
nix/interop-test.nix
··· 216 216 "grep -r 'zsmtp to postfix utf8' /var/spool/mail/alice/", timeout=60 217 217 ) 218 218 219 + # Exim advertises only PLAIN and LOGIN, both of which put the password 220 + # on the wire, so the client refuses them over this plaintext loopback 221 + # connection unless it is told to allow it. 219 222 with subtest("zsmtp client to exim, AUTH PLAIN"): 220 223 deliver( 221 - "--user alice --password secret --auth-method plain", 224 + "--allow-cleartext-auth --user alice --password secret --auth-method plain", 222 225 2625, 223 226 "zsmtp to exim auth plain", 224 227 "/var/spool/exim-mail/alice", ··· 226 229 227 230 with subtest("zsmtp client to exim, AUTH LOGIN"): 228 231 deliver( 229 - "--user alice --password secret --auth-method login", 232 + "--allow-cleartext-auth --user alice --password secret --auth-method login", 230 233 2625, 231 234 "zsmtp to exim auth login", 232 235 "/var/spool/exim-mail/alice", ··· 235 238 with subtest("zsmtp client to exim, wrong password is rejected"): 236 239 machine.fail( 237 240 "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'" 238 - " | zsmtp send --user alice --password wrong 127.0.0.1 2625" 241 + " | zsmtp send --allow-cleartext-auth --user alice --password wrong" 242 + " 127.0.0.1 2625 bob@example.com alice@localhost" 243 + ) 244 + 245 + # The refusal itself, which is what stops a password reaching the network 246 + # by accident: the same delivery without the opt-in must not go through. 247 + with subtest("zsmtp client refuses cleartext AUTH without the opt-in"): 248 + machine.fail( 249 + "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'" 250 + " | zsmtp send --user alice --password secret 127.0.0.1 2625" 239 251 " bob@example.com alice@localhost" 252 + ) 253 + # ...and over STARTTLS it goes through with no opt-in at all. 254 + deliver( 255 + "--starttls --insecure --user alice --password secret", 256 + 2625, 257 + "zsmtp to exim auth over starttls", 258 + "/var/spool/exim-mail/alice", 240 259 ) 241 260 242 261 with subtest("swaks to zsmtp server, AUTH PLAIN"):
+185 -17
src/Client.zig
··· 29 29 /// The most recent reply read from the server. Useful for reporting the 30 30 /// server's actual response after an `error.UnexpectedReply`. 31 31 last_reply: ?Reply = null, 32 + /// Whether the transport is encrypted. This library cannot tell on its own 33 + /// — it is handed a reader and a writer and has no idea what is under them 34 + /// — so it assumes the worst and the caller says otherwise. 35 + /// 36 + /// `setTransport` takes the answer as an argument, which covers a STARTTLS 37 + /// upgrade. A session that speaks TLS from the first byte (port 465) hands 38 + /// `init` an already-encrypted transport, and sets this itself. 39 + security: Security = .plaintext, 40 + /// Permits `authenticate`, `authPlain` and `authLogin` to send credentials 41 + /// over a `.plaintext` transport, which they otherwise refuse with 42 + /// `error.InsecureTransport`. 43 + /// 44 + /// The honest use is a connection protected by something outside this 45 + /// library's view — a unix socket, an SSH tunnel, a loopback test — where 46 + /// setting `security` to `.encrypted` would be a lie. Anything else is 47 + /// handing the password to the network. 48 + allow_cleartext_auth: bool = false, 49 + 50 + /// Whether the transport encrypts what is written to it. 51 + pub const Security = enum { plaintext, encrypted }; 32 52 33 53 pub const Error = error{ 34 54 WriteFailed, ··· 39 59 ReplyTooLong, 40 60 /// The server answered with an unexpected code; see `last_reply`. 41 61 UnexpectedReply, 62 + }; 63 + 64 + pub const ArgumentError = error{ 65 + /// An argument contained CR, LF or NUL and was not sent. See 66 + /// `protocol.isSafeArgument` for why those three bytes and no others. 67 + UnsafeArgument, 42 68 }; 43 69 44 70 /// Extensions advertised in the server's EHLO response. ··· 138 164 /// Sends EHLO ([RFC 5321 §4.1.1.1](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.1.1)) 139 165 /// and returns the extensions the server advertised, falling back 140 166 /// to plain HELO for servers that do not speak ESMTP. 141 - pub fn hello(c: *Client, client_name: []const u8) Error!Extensions { 167 + pub fn hello(c: *Client, client_name: []const u8) (Error || ArgumentError)!Extensions { 168 + if (!protocol.isSafeArgument(client_name)) return error.UnsafeArgument; 142 169 try c.send("EHLO {s}", .{client_name}); 143 170 const reply = try c.readReply(); 144 171 if (reply.isPositiveCompletion()) return Extensions.parse(reply); ··· 162 189 } 163 190 164 191 /// Replaces the session's transport, typically with a TLS reader/writer 165 - /// after `starttls`. 166 - pub fn setTransport(c: *Client, reader: *Io.Reader, writer: *Io.Writer) void { 192 + /// after `starttls`, and records whether the new one is encrypted. Pass 193 + /// `.encrypted` for a TLS transport; that is what lets `authenticate` use a 194 + /// mechanism that sends the password. 195 + pub fn setTransport(c: *Client, reader: *Io.Reader, writer: *Io.Writer, security: Security) void { 167 196 c.reader = reader; 168 197 c.writer = writer; 198 + c.security = security; 169 199 } 170 200 171 - pub const AuthError = Error || error{ 201 + pub const AuthError = Error || ArgumentError || error{ 172 202 CredentialsTooLong, 203 + /// The transport is not encrypted and the mechanism would have put the 204 + /// password on the wire in the clear. Upgrade the session with 205 + /// `starttls`, or set `allow_cleartext_auth` if the connection is 206 + /// protected by something this library cannot see. 207 + InsecureTransport, 173 208 /// The server rejected the credentials; see `last_reply`. 174 209 AuthenticationFailed, 175 210 /// The server's CRAM-MD5 challenge was not valid base64. ··· 178 213 NoSupportedMechanism, 179 214 }; 180 215 181 - /// Authenticates with the best mechanism the server advertised (PLAIN, 182 - /// then LOGIN, then CRAM-MD5). Note that PLAIN and LOGIN send credentials 183 - /// unprotected: use TLS on real networks. 216 + /// Authenticates with the best mechanism the server advertised, which 217 + /// depends on `security`. 218 + /// 219 + /// Over an encrypted transport that is PLAIN, then LOGIN, then CRAM-MD5: 220 + /// the network cannot read any of them, so the order is by how reliably 221 + /// servers implement them. Over a plaintext one the order inverts to 222 + /// CRAM-MD5 first, because it is the only one of the three that does not 223 + /// put the password on the wire; if the server does not offer it, the 224 + /// remaining mechanisms are refused with `error.InsecureTransport` rather 225 + /// than used, unless `allow_cleartext_auth` says otherwise. 184 226 pub fn authenticate(c: *Client, extensions: Extensions, username: []const u8, password: []const u8) AuthError!void { 227 + if (c.security == .plaintext and extensions.auth.cram_md5) 228 + return c.authCramMd5(username, password); 185 229 if (extensions.auth.plain) return c.authPlain("", username, password); 186 230 if (extensions.auth.login) return c.authLogin(username, password); 187 231 if (extensions.auth.cram_md5) return c.authCramMd5(username, password); 188 232 return error.NoSupportedMechanism; 189 233 } 190 234 235 + /// Refuses a mechanism that would transmit the password unprotected. 236 + fn requireConfidentiality(c: *Client) AuthError!void { 237 + if (c.security == .encrypted or c.allow_cleartext_auth) return; 238 + return error.InsecureTransport; 239 + } 240 + 191 241 /// Authenticates with AUTH PLAIN ([RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616)). 192 242 /// Pass an empty `authzid` unless 193 - /// you need to act on behalf of another identity. Note that sending 194 - /// credentials over an unencrypted connection exposes them to the network. 243 + /// you need to act on behalf of another identity. 244 + /// 245 + /// The credentials cross the wire in the clear (base64 is not encryption), 246 + /// so this returns `error.InsecureTransport` unless `security` is 247 + /// `.encrypted` or `allow_cleartext_auth` is set. 195 248 pub fn authPlain(c: *Client, authzid: []const u8, username: []const u8, password: []const u8) AuthError!void { 249 + try c.requireConfidentiality(); 250 + // NUL separates the three fields, so one hidden in a field would move 251 + // the boundaries and authenticate as somebody else. 252 + if (!protocol.isSafeArgument(authzid) or !protocol.isSafeArgument(username) or 253 + !protocol.isSafeArgument(password)) return error.UnsafeArgument; 196 254 var plain_buf: [512]u8 = undefined; 197 255 var plain: Io.Writer = .fixed(&plain_buf); 198 256 plain.print("{s}\x00{s}\x00{s}", .{ authzid, username, password }) catch ··· 206 264 /// Authenticates with AUTH LOGIN, the legacy two-step username/password 207 265 /// exchange still required by some servers (no RFC; the de-facto 208 266 /// [draft-murchison-sasl-login](https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00) 209 - /// mechanism). 267 + /// mechanism). Like AUTH PLAIN it sends the credentials in the clear, so 268 + /// it returns `error.InsecureTransport` unless `security` is `.encrypted` 269 + /// or `allow_cleartext_auth` is set. 210 270 pub fn authLogin(c: *Client, username: []const u8, password: []const u8) AuthError!void { 271 + try c.requireConfidentiality(); 211 272 try c.send("AUTH LOGIN", .{}); 212 273 _ = try c.expect(334); // Username: prompt 213 274 try c.sendBase64(username); ··· 218 279 219 280 /// Authenticates with AUTH CRAM-MD5 ([RFC 2195](https://datatracker.ietf.org/doc/html/rfc2195)): 220 281 /// the password never crosses 221 - /// the wire, only an HMAC-MD5 of the server's challenge. 282 + /// the wire, only an HMAC-MD5 of the server's challenge — which is why this 283 + /// one is allowed over a plaintext transport, and why `authenticate` 284 + /// prefers it there. The challenge is still replayable and MD5 is long 285 + /// past retirement, so it is a way to avoid handing over the password, not 286 + /// a substitute for TLS. 222 287 pub fn authCramMd5(c: *Client, username: []const u8, password: []const u8) AuthError!void { 288 + if (!protocol.isSafeArgument(username)) return error.UnsafeArgument; 223 289 try c.send("AUTH CRAM-MD5", .{}); 224 290 const reply = try c.expect(334); 225 291 ··· 257 323 258 324 /// Starts a mail transaction. An empty `from` sends the null reverse-path 259 325 /// (`MAIL FROM:<>`), used for bounces. 260 - pub fn mailFrom(c: *Client, from: []const u8) Error!void { 326 + /// 327 + /// Returns `error.UnsafeArgument` for an address that would break out of 328 + /// the command line; see `protocol.isSafeArgument`. 329 + pub fn mailFrom(c: *Client, from: []const u8) (Error || ArgumentError)!void { 330 + if (!protocol.isSafeArgument(from)) return error.UnsafeArgument; 261 331 try c.send("MAIL FROM:<{s}>", .{from}); 262 332 _ = try c.expectClass(2); 263 333 } 264 334 265 - pub fn rcptTo(c: *Client, to: []const u8) Error!void { 335 + /// Adds a recipient to the current transaction. Returns 336 + /// `error.UnsafeArgument` for an address that would break out of the 337 + /// command line; see `protocol.isSafeArgument`. 338 + pub fn rcptTo(c: *Client, to: []const u8) (Error || ArgumentError)!void { 339 + if (!protocol.isSafeArgument(to)) return error.UnsafeArgument; 266 340 try c.send("RCPT TO:<{s}>", .{to}); 267 341 _ = try c.expectClass(2); 268 342 } ··· 418 492 /// ([RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531)) so the 419 493 /// envelope addresses and message headers may contain UTF-8. Use only when 420 494 /// `Extensions.smtputf8` was advertised. 421 - pub fn mailFromUtf8(c: *Client, from: []const u8) Error!void { 495 + pub fn mailFromUtf8(c: *Client, from: []const u8) (Error || ArgumentError)!void { 496 + if (!protocol.isSafeArgument(from)) return error.UnsafeArgument; 422 497 try c.send("MAIL FROM:<{s}> SMTPUTF8", .{from}); 423 498 _ = try c.expectClass(2); 424 499 } ··· 448 523 449 524 /// Runs a complete mail transaction: MAIL FROM, one RCPT TO per recipient, 450 525 /// then DATA. Call after `greet` and `hello`. 451 - pub fn sendMail(c: *Client, from: []const u8, recipients: []const []const u8, message_data: []const u8) Error!void { 526 + pub fn sendMail(c: *Client, from: []const u8, recipients: []const []const u8, message_data: []const u8) (Error || ArgumentError)!void { 452 527 try c.mailFrom(from); 453 528 for (recipients) |recipient| try c.rcptTo(recipient); 454 529 try c.sendMessage(message_data); ··· 586 661 var tls_reader: Io.Reader = .fixed(tls_responses); 587 662 var tls_out_buf: [256]u8 = undefined; 588 663 var tls_writer: Io.Writer = .fixed(&tls_out_buf); 589 - client.setTransport(&tls_reader, &tls_writer); 664 + client.setTransport(&tls_reader, &tls_writer, .encrypted); 590 665 591 666 const tls_ext = try client.hello("client.example.org"); 592 667 try std.testing.expect(!tls_ext.starttls); ··· 605 680 var writer: Io.Writer = .fixed(&out_buf); 606 681 var reply_buf: [256]u8 = undefined; 607 682 var client: Client = .init(&reader, &writer, &reply_buf); 683 + client.security = .encrypted; // PLAIN is refused in the clear. 608 684 609 685 try client.authPlain("", "user", "pass"); 610 686 // base64("\x00user\x00pass") ··· 618 694 var writer: Io.Writer = .fixed(&out_buf); 619 695 var reply_buf: [256]u8 = undefined; 620 696 var client: Client = .init(&reader, &writer, &reply_buf); 697 + client.security = .encrypted; // LOGIN is refused in the clear. 621 698 622 699 try client.authLogin("user", "pass"); 623 700 try std.testing.expectEqualStrings( ··· 668 745 } 669 746 } 670 747 748 + test "an address carrying CRLF cannot inject a command" { 749 + // Without the check this would put a second RCPT on the wire. 750 + const smuggled = "bob@example.net>\r\nRCPT TO:<victim@example.net"; 751 + var reader: Io.Reader = .fixed("250 2.1.0 Ok\r\n"); 752 + var out_buf: [256]u8 = undefined; 753 + var writer: Io.Writer = .fixed(&out_buf); 754 + var reply_buf: [64]u8 = undefined; 755 + var client: Client = .init(&reader, &writer, &reply_buf); 756 + 757 + try std.testing.expectError(error.UnsafeArgument, client.rcptTo(smuggled)); 758 + try std.testing.expectError(error.UnsafeArgument, client.mailFrom(smuggled)); 759 + try std.testing.expectError(error.UnsafeArgument, client.mailFromUtf8(smuggled)); 760 + try std.testing.expectError(error.UnsafeArgument, client.hello("host\r\nQUIT")); 761 + // Nothing reached the wire, so the session is still where it was. 762 + try std.testing.expectEqualStrings("", writer.buffered()); 763 + } 764 + 765 + test "a NUL in a PLAIN field cannot shift the credential boundaries" { 766 + var reader: Io.Reader = .fixed("235 2.7.0 Accepted\r\n"); 767 + var out_buf: [256]u8 = undefined; 768 + var writer: Io.Writer = .fixed(&out_buf); 769 + var reply_buf: [64]u8 = undefined; 770 + var client: Client = .init(&reader, &writer, &reply_buf); 771 + client.security = .encrypted; 772 + 773 + // Decoded by the server as authzid "", username "admin", password "x". 774 + try std.testing.expectError( 775 + error.UnsafeArgument, 776 + client.authPlain("", "user\x00admin\x00x", "pass"), 777 + ); 778 + try std.testing.expectEqualStrings("", writer.buffered()); 779 + } 780 + 781 + test "cleartext mechanisms are refused on an unencrypted transport" { 782 + var reader: Io.Reader = .fixed(""); 783 + var out_buf: [256]u8 = undefined; 784 + var writer: Io.Writer = .fixed(&out_buf); 785 + var reply_buf: [64]u8 = undefined; 786 + var client: Client = .init(&reader, &writer, &reply_buf); 787 + 788 + try std.testing.expectError(error.InsecureTransport, client.authPlain("", "u", "p")); 789 + try std.testing.expectError(error.InsecureTransport, client.authLogin("u", "p")); 790 + // A server offering only those two leaves `authenticate` nothing to use. 791 + const cleartext_only: Extensions = .{ .auth = .{ .plain = true, .login = true } }; 792 + try std.testing.expectError( 793 + error.InsecureTransport, 794 + client.authenticate(cleartext_only, "u", "p"), 795 + ); 796 + try std.testing.expectEqualStrings("", writer.buffered()); 797 + } 798 + 799 + test "authenticate prefers CRAM-MD5 in the clear and PLAIN once encrypted" { 800 + const challenge = "334 PDE4OTYuNjk3MTcwOTUyQHBvc3RvZmZpY2UucmVzdG9uLm1jaS5uZXQ+\r\n" ++ 801 + "235 2.7.0 Accepted\r\n"; 802 + const advertised: Extensions = .{ 803 + .auth = .{ .plain = true, .login = true, .cram_md5 = true }, 804 + }; 805 + 806 + var reader: Io.Reader = .fixed(challenge); 807 + var out_buf: [256]u8 = undefined; 808 + var writer: Io.Writer = .fixed(&out_buf); 809 + var reply_buf: [256]u8 = undefined; 810 + var client: Client = .init(&reader, &writer, &reply_buf); 811 + 812 + // In the clear: the one mechanism that keeps the password off the wire. 813 + try client.authenticate(advertised, "tim", "tanstaaftanstaaf"); 814 + try std.testing.expect(std.mem.startsWith(u8, writer.buffered(), "AUTH CRAM-MD5\r\n")); 815 + 816 + var tls_reader: Io.Reader = .fixed("235 2.7.0 Accepted\r\n"); 817 + var tls_out_buf: [256]u8 = undefined; 818 + var tls_writer: Io.Writer = .fixed(&tls_out_buf); 819 + client.setTransport(&tls_reader, &tls_writer, .encrypted); 820 + 821 + try client.authenticate(advertised, "user", "pass"); 822 + try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", tls_writer.buffered()); 823 + } 824 + 825 + test "allow_cleartext_auth is the way past the refusal" { 826 + var reader: Io.Reader = .fixed("235 2.7.0 Accepted\r\n"); 827 + var out_buf: [256]u8 = undefined; 828 + var writer: Io.Writer = .fixed(&out_buf); 829 + var reply_buf: [64]u8 = undefined; 830 + var client: Client = .init(&reader, &writer, &reply_buf); 831 + client.allow_cleartext_auth = true; 832 + 833 + try client.authPlain("", "user", "pass"); 834 + try std.testing.expectEqualStrings("AUTH PLAIN AHVzZXIAcGFzcw==\r\n", writer.buffered()); 835 + } 836 + 671 837 test "rejected credentials surface AuthenticationFailed" { 672 838 const responses = "535 5.7.8 Authentication credentials invalid\r\n"; 673 839 var reader: Io.Reader = .fixed(responses); ··· 675 841 var writer: Io.Writer = .fixed(&out_buf); 676 842 var reply_buf: [256]u8 = undefined; 677 843 var client: Client = .init(&reader, &writer, &reply_buf); 844 + client.security = .encrypted; 678 845 679 846 try std.testing.expectError(error.AuthenticationFailed, client.authPlain("", "u", "p")); 680 847 try std.testing.expectEqual(@as(u16, 535), client.last_reply.?.code); ··· 727 894 var tls_reader: Io.Reader = .fixed(""); 728 895 var tls_out_buf: [16]u8 = undefined; 729 896 var tls_writer: Io.Writer = .fixed(&tls_out_buf); 730 - client.setTransport(&tls_reader, &tls_writer); 897 + client.setTransport(&tls_reader, &tls_writer, .encrypted); 731 898 try std.testing.expectEqual(&tls_reader, client.reader); 732 899 try std.testing.expectEqual(&tls_writer, client.writer); 900 + try std.testing.expectEqual(Security.encrypted, client.security); 733 901 } 734 902 735 903 test mailFrom {
+24 -10
src/main.zig
··· 3 3 4 4 //! Demo CLI for the zsmtp library. 5 5 //! 6 - //! zsmtp send [--tls|--starttls] [--insecure] [--chunking] [--smtputf8] 7 - //! [--user <u> --password <p>] 6 + //! zsmtp send [--tls|--starttls] [--insecure] [--allow-cleartext-auth] 7 + //! [--chunking] [--smtputf8] [--user <u> --password <p>] 8 8 //! [--auth-method plain|login|cram-md5] <host> <port> <from> <to>... 9 9 //! send a message read from stdin; --tls speaks TLS from the first 10 10 //! byte (port 465 style), --starttls upgrades after EHLO (port 587 11 11 //! style), --insecure skips certificate verification, --user/--password 12 12 //! authenticate with the best advertised mechanism (or the one forced 13 - //! by --auth-method) 13 + //! by --auth-method), and --allow-cleartext-auth permits a mechanism 14 + //! that sends the password over an unencrypted connection 14 15 //! zsmtp serve [--tls-cert <pem> --tls-key <pem> [--implicit-tls]] 15 16 //! [--auth <user>:<pass>] <port> 16 17 //! run a debug server on 127.0.0.1 that prints received messages; ··· 37 38 config.mode = .starttls; 38 39 } else if (std.mem.eql(u8, rest[0], "--insecure")) { 39 40 config.insecure = true; 41 + } else if (std.mem.eql(u8, rest[0], "--allow-cleartext-auth")) { 42 + config.allow_cleartext_auth = true; 40 43 } else if (std.mem.eql(u8, rest[0], "--chunking")) { 41 44 config.chunking = true; 42 45 } else if (std.mem.eql(u8, rest[0], "--smtputf8")) { ··· 103 106 const SendConfig = struct { 104 107 mode: enum { plain, tls, starttls } = .plain, 105 108 insecure: bool = false, 109 + allow_cleartext_auth: bool = false, 106 110 chunking: bool = false, 107 111 smtputf8: bool = false, 108 112 username: ?[]const u8 = null, ··· 113 117 fn usage() noreturn { 114 118 std.log.err( 115 119 \\usage: 116 - \\ zsmtp send [--tls|--starttls] [--insecure] [--user <u> --password <p>] 120 + \\ zsmtp send [--tls|--starttls] [--insecure] [--allow-cleartext-auth] 121 + \\ [--user <u> --password <p>] 117 122 \\ [--auth-method plain|login|cram-md5] <host> <port> <from> <to>... 118 123 \\ (message is read from stdin) 119 124 \\ zsmtp serve [--tls-cert <pem> --tls-key <pem> [--implicit-tls]] ··· 158 163 159 164 var reply_buf: [1024]u8 = undefined; 160 165 var client: zsmtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf); 166 + client.allow_cleartext_auth = config.allow_cleartext_auth; 161 167 162 168 if (config.mode == .tls) { 163 169 try tls.init(io, arena, &stream_reader.interface, &stream_writer.interface, tls_options); 164 170 tls_active = true; 165 - client.setTransport(tls.reader(), tls.writer()); 171 + client.setTransport(tls.reader(), tls.writer(), .encrypted); 166 172 } 167 173 168 174 _ = try client.greet(); ··· 172 178 try client.starttls(); 173 179 try tls.init(io, arena, &stream_reader.interface, &stream_writer.interface, tls_options); 174 180 tls_active = true; 175 - client.setTransport(tls.reader(), tls.writer()); 181 + client.setTransport(tls.reader(), tls.writer(), .encrypted); 176 182 extensions = try client.hello("localhost"); 177 183 } 178 184 ··· 185 191 .cram_md5 => client.authCramMd5(username, password), 186 192 }; 187 193 result catch |err| { 188 - if (err == error.AuthenticationFailed) { 189 - const reply = client.last_reply.?; 190 - std.log.err("authentication failed: {d} {s}", .{ reply.code, reply.text }); 194 + switch (err) { 195 + error.AuthenticationFailed => { 196 + const reply = client.last_reply.?; 197 + std.log.err("authentication failed: {d} {s}", .{ reply.code, reply.text }); 198 + }, 199 + error.InsecureTransport => std.log.err( 200 + "refusing to send credentials over an unencrypted connection; " ++ 201 + "use --starttls or --tls, or pass --allow-cleartext-auth", 202 + .{}, 203 + ), 204 + else => {}, 191 205 } 192 206 return err; 193 207 }; ··· 221 235 message: *Io.Reader, 222 236 chunking: bool, 223 237 smtputf8: bool, 224 - ) zsmtp.Client.Error!void { 238 + ) (zsmtp.Client.Error || zsmtp.Client.ArgumentError)!void { 225 239 if (smtputf8) try client.mailFromUtf8(from) else try client.mailFrom(from); 226 240 for (recipients) |recipient| try client.rcptTo(recipient); 227 241 if (chunking) {
+30
src/protocol.zig
··· 11 11 12 12 pub const crlf = "\r\n"; 13 13 14 + /// Bytes that may never appear in a command argument. 15 + /// 16 + /// CR and LF end the command line, so a value carrying either one lets 17 + /// whatever follows it be read by the server as further SMTP commands — an 18 + /// address of `a@b>\r\nRCPT TO:<victim@c` turns one recipient into two. 19 + /// NUL is here because it separates the three fields of an SASL PLAIN 20 + /// response, where a value carrying one silently shifts the boundary 21 + /// between authorization identity, username and password. 22 + pub const forbidden_in_argument = "\r\n\x00"; 23 + 24 + /// Whether `text` is safe to write into a command line as an argument. 25 + /// 26 + /// This is a framing check, not address validation: it says that `text` 27 + /// cannot end the line early, not that it is a well-formed mailbox. The 28 + /// full RFC 5321 path grammar is deliberately not enforced, because plenty 29 + /// of addresses in real use do not satisfy it and a client that refused to 30 + /// carry them would be the wrong tool. Callers building commands from 31 + /// untrusted input should check here and reject what fails. 32 + pub fn isSafeArgument(text: []const u8) bool { 33 + return std.mem.findAny(u8, text, forbidden_in_argument) == null; 34 + } 35 + 36 + test isSafeArgument { 37 + try std.testing.expect(isSafeArgument("alice@example.com")); 38 + try std.testing.expect(isSafeArgument("\"odd name\"@example.com")); 39 + try std.testing.expect(!isSafeArgument("a@b>\r\nRCPT TO:<victim@c")); 40 + try std.testing.expect(!isSafeArgument("a@b\nMAIL FROM:<c@d>")); 41 + try std.testing.expect(!isSafeArgument("alice\x00root")); 42 + } 43 + 14 44 pub const ReadLineError = error{ 15 45 ReadFailed, 16 46 EndOfStream,