email: bound the IMAP read and keep one bad message from blocking the poll
The literal size came off the wire with no cap, so the server chose the
allocation. A {2147483647} literal was a 2GB make before a byte arrived, and one
ordinary mail with a 60MB attachment was 60MB of peak RSS on a box already
holding a 1.7B model resident, all of it discarded afterwards by plaintextBody.
Literals are now capped at MaxMessageBytes, and a larger one is drained and
reported as ErrMessageTooLarge without being kept. Reads are chunked with a
deadline refresh, so the timeout is an idle timeout again rather than a budget
for the whole message.
FetchSince returned on the first fetch error, though its comment described a
continue. One oversized message at the top of the window hid every older message
behind it, on that poll and on every poll after it. Failures are now collected
and the rest of the mailbox is read. An oversized UID is retired as bulk, since
it will be the same size next time and the poller marks bulk seen.
Timeout zero was accepted and disabled the dial timeout and every socket
deadline, which parks the poller forever on a dead server with his credential
live in a TLS state. It is now rejected like an empty address.
A FETCH answered without a literal was indistinguishable from a vanished
message and dropped with no log line. Login now rejects a credential containing
a line break instead of stripping it and failing on the server's generic NO.
untagged matches the whole key, not a prefix. RunWith is gone: the dial seam is
an unexported field again, reachable only through export_test.go, so no code
outside the package can hand the reader a cleartext transport and the password.
Found in review of #63.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
This commit is contained in:
+117
-8
@@ -3,6 +3,7 @@ package email
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -32,6 +33,31 @@ import (
|
||||
// option to get it wrong once.
|
||||
const DefaultIMAPPort = "993"
|
||||
|
||||
// MaxMessageBytes — the largest message this client will read into memory.
|
||||
//
|
||||
// The literal size comes off the wire, so an unbounded read is an allocation
|
||||
// the server picks: "{2147483647}" is a 2GB make() before a single byte
|
||||
// arrives, and one ordinary mail with a 60MB attachment is a 60MB peak RSS on a
|
||||
// box already holding a 1.7B model resident. All of it would then be thrown
|
||||
// away, because plaintextBody skips attachments and the body is truncated to
|
||||
// MaxBodyBytes anyway.
|
||||
//
|
||||
// 2 MiB is well above what prose plus quoted history plus base64 HTML needs and
|
||||
// well below what hurts. A larger message is drained and reported as
|
||||
// ErrMessageTooLarge rather than read.
|
||||
const MaxMessageBytes = 2 << 20
|
||||
|
||||
// readChunk — how much of a literal is read between deadline refreshes. The
|
||||
// per-connection timeout must stay an IDLE timeout: with one deadline around
|
||||
// the whole read it becomes a whole-message budget, and a healthy but slow
|
||||
// uplink then fails the same message on every poll forever.
|
||||
const readChunk = 64 << 10
|
||||
|
||||
// ErrMessageTooLarge — the server announced a literal above MaxMessageBytes.
|
||||
// The connection stays usable (the bytes are drained), and the caller decides
|
||||
// what to do with the UID. FetchSince retires it rather than retrying it.
|
||||
var ErrMessageTooLarge = errors.New("email: message larger than the read cap")
|
||||
|
||||
// Conn — one authenticated IMAP connection. Not safe for concurrent use; the
|
||||
// poller drives one connection at a time.
|
||||
type Conn struct {
|
||||
@@ -78,6 +104,18 @@ func (c *Conn) Close() error { return c.rwc.Close() }
|
||||
// never stored on the Conn: nothing in this package keeps a credential alive
|
||||
// past the command that uses it, so no struct dump or panic trace can carry it.
|
||||
func (c *Conn) Login(user, pass string) error {
|
||||
// A credential with a line break in it is rejected here, not silently
|
||||
// repaired. quote() strips CR and LF so a stray newline can never become a
|
||||
// second command, but stripping alone means a password file that picked up
|
||||
// a newline authenticates as a DIFFERENT string and comes back as the
|
||||
// server's generic NO, which is a long debugging session. This error names
|
||||
// the problem and cannot leak the value.
|
||||
if strings.ContainsAny(user, "\r\n") {
|
||||
return fmt.Errorf("email: login: username contains a line break")
|
||||
}
|
||||
if strings.ContainsAny(pass, "\r\n") {
|
||||
return fmt.Errorf("email: login: password contains a line break")
|
||||
}
|
||||
// The command line itself is never logged (see exec) — a LOGIN line IS the
|
||||
// credential.
|
||||
if _, err := c.exec(fmt.Sprintf("LOGIN %s %s", quote(user), quote(pass))); err != nil {
|
||||
@@ -130,34 +168,60 @@ var literalSize = regexp.MustCompile(`\{(\d+)\}$`)
|
||||
// Fetch returns the raw RFC 5322 bytes of one message, by UID.
|
||||
//
|
||||
// Returns (nil, nil) when the UID no longer exists — a message he deleted
|
||||
// between SEARCH and FETCH is normal, not an error.
|
||||
// between SEARCH and FETCH is normal, not an error. A FETCH response that came
|
||||
// back with no literal in it is NOT that case and is an error, so a message
|
||||
// that exists and was readable is never dropped without a log line.
|
||||
//
|
||||
// A literal above MaxMessageBytes is drained without being kept and reported as
|
||||
// ErrMessageTooLarge.
|
||||
func (c *Conn) Fetch(uid uint32) ([]byte, error) {
|
||||
tag := c.nextTag()
|
||||
if err := c.send(fmt.Sprintf("%s UID FETCH %d (BODY.PEEK[])", tag, uid)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []byte
|
||||
var sawFetch, tooLarge bool
|
||||
for {
|
||||
line, err := c.readLine()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("email: fetch %d: %w", uid, err)
|
||||
}
|
||||
if done, err := c.tagged(tag, line); done {
|
||||
if err != nil {
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("email: fetch %d: %w", uid, err)
|
||||
case tooLarge:
|
||||
return nil, fmt.Errorf("email: fetch %d: %w", uid, ErrMessageTooLarge)
|
||||
case sawFetch && raw == nil:
|
||||
// The server answered for this UID but not with a literal (a
|
||||
// quoted string, say). Silently skipping it would look exactly
|
||||
// like a vanished message.
|
||||
return nil, fmt.Errorf("email: fetch %d: no message literal in the FETCH response", uid)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
if strings.HasPrefix(line, "* ") && strings.Contains(line, " FETCH ") {
|
||||
sawFetch = true
|
||||
}
|
||||
m := literalSize.FindStringSubmatch(strings.TrimSpace(line))
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err != nil {
|
||||
if err != nil || n < 0 {
|
||||
continue
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(c.r, buf); err != nil {
|
||||
if n > MaxMessageBytes {
|
||||
// Drained rather than read: the stream has to stay aligned for the
|
||||
// tagged completion, but nothing is allocated and nothing is parsed.
|
||||
tooLarge = true
|
||||
if err := c.discard(int64(n)); err != nil {
|
||||
return nil, fmt.Errorf("email: fetch %d: drain literal: %w", uid, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
buf, err := c.readN(n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("email: fetch %d: literal: %w", uid, err)
|
||||
}
|
||||
if raw == nil {
|
||||
@@ -203,8 +267,8 @@ func (c *Conn) exec(cmd string) ([]string, error) {
|
||||
// send asks for one outside Fetch, but skip it if it appears so the
|
||||
// stream stays aligned.
|
||||
if m := literalSize.FindStringSubmatch(strings.TrimSpace(line)); m != nil {
|
||||
if n, err := strconv.Atoi(m[1]); err == nil {
|
||||
if _, err := io.CopyN(io.Discard, c.r, int64(n)); err != nil {
|
||||
if n, err := strconv.Atoi(m[1]); err == nil && n > 0 {
|
||||
if err := c.discard(int64(n)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -238,6 +302,43 @@ func (c *Conn) send(line string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// readN reads exactly n bytes, refreshing the deadline every readChunk so the
|
||||
// timeout stays an idle timeout rather than a budget for the whole literal.
|
||||
func (c *Conn) readN(n int) ([]byte, error) {
|
||||
buf := make([]byte, n)
|
||||
for off := 0; off < n; {
|
||||
end := off + readChunk
|
||||
if end > n {
|
||||
end = n
|
||||
}
|
||||
c.setDeadline()
|
||||
got, err := io.ReadFull(c.r, buf[off:end])
|
||||
off += got
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// discard throws away n bytes of literal, same chunked deadline refresh as
|
||||
// readN and no allocation proportional to n.
|
||||
func (c *Conn) discard(n int64) error {
|
||||
for n > 0 {
|
||||
chunk := int64(readChunk)
|
||||
if chunk > n {
|
||||
chunk = n
|
||||
}
|
||||
c.setDeadline()
|
||||
got, err := io.CopyN(io.Discard, c.r, chunk)
|
||||
n -= got
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Conn) readLine() (string, error) {
|
||||
c.setDeadline()
|
||||
line, err := c.r.ReadString('\n')
|
||||
@@ -259,6 +360,10 @@ func (c *Conn) setDeadline() {
|
||||
}
|
||||
|
||||
// untagged splits "* SEARCH 1 2 3" into its payload when the key matches.
|
||||
//
|
||||
// The key must be the whole word: a prefix test would also match a future
|
||||
// extension's "* SEARCHRES", and reading its payload as UIDs is the kind of
|
||||
// thing that ages badly next to an IMAP capability nobody asked for.
|
||||
func untagged(line, key string) (string, bool) {
|
||||
if !strings.HasPrefix(line, "* ") {
|
||||
return "", false
|
||||
@@ -267,7 +372,11 @@ func untagged(line, key string) (string, bool) {
|
||||
if !strings.HasPrefix(rest, key) {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(rest[len(key):]), true
|
||||
rest = rest[len(key):]
|
||||
if rest != "" && rest[0] != ' ' && rest[0] != '\t' {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(rest), true
|
||||
}
|
||||
|
||||
// quote renders an IMAP quoted string. Passwords routinely contain characters
|
||||
|
||||
Reference in New Issue
Block a user