Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/platform/imapclient/imapclient.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/platform/imapclient/imapclient.go |
| Original SHA-256 | 9fa4702f3357a1dbb52f962317c43dac46d8331ea3616fc388a1036ac4e613b5 |
| Git blob SHA | 3fa7d607d464ba32dc3e31fa76c1abdca663d82a |
| Reading-copy SHA-256 | 9fa4702f3357a1dbb52f962317c43dac46d8331ea3616fc388a1036ac4e613b5 |
Recovered source transcript
Download complete source text ↓294 lines · 9.7 KB
// Package imapclient is a deliberately minimal IMAP4rev1 reader: connect, authenticate, select a mailbox,
// search by date, and fetch a NAMED SET OF HEADERS. It cannot fetch a message body, and that is a property of
// the code rather than a convention somebody has to remember.
//
// WHY IT IS WRITTEN HERE RATHER THAN TAKEN FROM A LIBRARY. This codebase already made this call once, for
// TOTP, and recorded the reasoning: implemented in house on the standard library so every primitive stays
// provably inside the FIPS validated module and there is no external crypto to re verify under fips140=on.
// The same argument applies with more force to a mail client, which is a network client handling
// authentication on a platform holding controlled unclassified information. Every byte of crypto here is
// crypto/tls. The subset of IMAP needed is small enough that writing it is cheaper than auditing somebody
// else's.
//
// IT IS NOT A GENERAL IMAP CLIENT AND MUST NOT GROW INTO ONE. It does the five commands the activity sync
// needs. A feature that needs more of IMAP should be a conversation about whether it needs IMAP.
package imapclient
import (
"bufio"
"crypto/tls"
"errors"
"fmt"
"net"
"strconv"
"strings"
"time"
)
var (
ErrAuth = errors.New("imap: the mail server refused those credentials")
ErrNoMailbox = errors.New("imap: no such mailbox")
ErrProtocol = errors.New("imap: unexpected response from the mail server")
ErrBlockedHost = errors.New("imap: that mail host resolves to an address inside this network and is refused")
)
// Config is one mailbox connection. There is no security mode: it is ALWAYS implicit TLS, because port 993
// with TLS from the first byte is universally supported and a negotiated upgrade is one more thing that can
// silently fail open.
type Config struct {
Host string
Port int
Username string
Password string
}
// Header is what one message yields. No body, ever.
type Header struct {
MessageID string
From string
To []string
Cc []string
Subject string
Date time.Time
}
// Client is a live connection. Always Close it.
type Client struct {
conn net.Conn
r *bufio.Reader
seq int
}
const (
dialTimeout = 15 * time.Second
ioTimeout = 45 * time.Second
// maxLine bounds a single response line. A hostile or broken server must not be able to make this
// allocate without limit.
maxLine = 1 << 20
// maxFetch bounds how many messages one sync pulls, so a first connection to a very busy mailbox is a
// bounded amount of work rather than an afternoon of traffic.
maxFetch = 500
)
// Dial connects and authenticates.
//
// THE HOST IS RESOLVED AND CHECKED BEFORE ANYTHING IS DIALLED, then the CHECKED ADDRESS is connected to
// rather than the name. That is the same rebinding safe rule the mail sender follows, and it exists because a
// customer supplied host name is a server side request forgery primitive unless it is bounded.
func Dial(cfg Config) (*Client, error) {
if cfg.Host == "" || cfg.Port <= 0 {
return nil, errors.New("imap: no mail host configured")
}
ip, err := resolveAllowed(cfg.Host)
if err != nil {
return nil, err
}
addr := net.JoinHostPort(ip.String(), strconv.Itoa(cfg.Port))
// ServerName is the NAME the customer configured, never the resolved address, so certificate validation
// still checks what it is supposed to check.
conn, err := tls.DialWithDialer(
&net.Dialer{Timeout: dialTimeout}, "tcp", addr,
&tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12})
if err != nil {
return nil, fmt.Errorf("imap: connect to %s: %w", cfg.Host, err)
}
_ = conn.SetDeadline(time.Now().Add(ioTimeout))
c := &Client{conn: conn, r: bufio.NewReaderSize(conn, 64<<10)}
// The greeting.
line, err := c.readLine()
if err != nil {
conn.Close()
return nil, fmt.Errorf("imap: greeting: %w", err)
}
if !strings.HasPrefix(line, "* OK") {
conn.Close()
return nil, fmt.Errorf("%w: greeting was %q", ErrProtocol, clip(line, 120))
}
if _, err := c.cmd(fmt.Sprintf("LOGIN %s %s", quote(cfg.Username), quote(cfg.Password))); err != nil {
conn.Close()
// The server's own words are NOT returned here. An authentication failure message can echo the
// credentials it rejected, and this error is shown to a user and stored on a row.
return nil, ErrAuth
}
return c, nil
}
func (c *Client) Close() error {
if c.conn == nil {
return nil
}
_, _ = c.cmd("LOGOUT")
return c.conn.Close()
}
// Select opens a mailbox read only. EXAMINE rather than SELECT, deliberately: it opens without marking
// anything read, so syncing a recruiter's mailbox never changes what their mail client shows them. A sync
// that silently marked mail read would be noticed once and switched off forever.
func (c *Client) Select(mailbox string) error {
if _, err := c.cmd("EXAMINE " + quote(mailbox)); err != nil {
return fmt.Errorf("%w: %s", ErrNoMailbox, mailbox)
}
return nil
}
// SearchSince returns the identifiers of messages since a date, newest bounded by maxFetch.
func (c *Client) SearchSince(since time.Time) ([]string, error) {
// The IMAP date format is fixed and locale free.
lines, err := c.cmd("UID SEARCH SINCE " + since.Format("02-Jan-2006"))
if err != nil {
return nil, err
}
var ids []string
for _, l := range lines {
if !strings.HasPrefix(l, "* SEARCH") {
continue
}
for _, f := range strings.Fields(strings.TrimPrefix(l, "* SEARCH")) {
if _, e := strconv.Atoi(f); e == nil {
ids = append(ids, f)
}
}
}
// The newest are the interesting ones, so an oversized result keeps the tail rather than the head.
if len(ids) > maxFetch {
ids = ids[len(ids)-maxFetch:]
}
return ids, nil
}
// FetchHeaders retrieves the named header fields for the given identifiers.
//
// BODY.PEEK is used rather than BODY, so fetching does not set the Seen flag. PEEK and EXAMINE together mean
// this is a genuinely read only operation on somebody's mailbox.
//
// THE FIELD LIST IS FIXED AND CANNOT BE WIDENED BY A CALLER. That is what makes "no bodies" a property of
// the code: there is no parameter through which a body could be requested.
func (c *Client) FetchHeaders(uids []string) ([]Header, error) {
out := make([]Header, 0, len(uids))
// Batched, because one FETCH per message is one round trip per message and a mailbox has thousands.
const batch = 50
for i := 0; i < len(uids); i += batch {
end := i + batch
if end > len(uids) {
end = len(uids)
}
set := strings.Join(uids[i:end], ",")
lines, err := c.cmd("UID FETCH " + set +
" (BODY.PEEK[HEADER.FIELDS (MESSAGE-ID FROM TO CC SUBJECT DATE)])")
if err != nil {
return out, err
}
out = append(out, parseFetch(lines)...)
}
return out, nil
}
// cmd sends a tagged command and reads until its completion line.
func (c *Client) cmd(s string) ([]string, error) {
c.seq++
tag := fmt.Sprintf("a%03d", c.seq)
_ = c.conn.SetDeadline(time.Now().Add(ioTimeout))
if _, err := c.conn.Write([]byte(tag + " " + s + "\r\n")); err != nil {
return nil, fmt.Errorf("imap: write: %w", err)
}
var lines []string
for {
line, err := c.readLine()
if err != nil {
return lines, fmt.Errorf("imap: read: %w", err)
}
if strings.HasPrefix(line, tag+" ") {
rest := strings.TrimPrefix(line, tag+" ")
if strings.HasPrefix(rest, "OK") {
return lines, nil
}
// The server's failure text is deliberately NOT wrapped into the error, for the same reason the
// login path does not: it can echo what was sent.
return lines, ErrProtocol
}
lines = append(lines, line)
if len(lines) > 200000 {
return lines, fmt.Errorf("%w: response did not terminate", ErrProtocol)
}
}
}
func (c *Client) readLine() (string, error) {
line, err := c.r.ReadString('\n')
if len(line) > maxLine {
return "", fmt.Errorf("%w: response line exceeded %d bytes", ErrProtocol, maxLine)
}
return strings.TrimRight(line, "\r\n"), err
}
// quote produces an IMAP quoted string. Backslash and quote are escaped, and a control character is REMOVED
// rather than escaped, because a newline inside a command is command injection against the IMAP session in
// exactly the way a newline in a mail header is header injection.
func quote(s string) string {
var b strings.Builder
b.WriteByte('"')
for i := 0; i < len(s); i++ {
ch := s[i]
if ch < 0x20 || ch == 0x7f {
continue
}
if ch == '"' || ch == '\\' {
b.WriteByte('\\')
}
b.WriteByte(ch)
}
b.WriteByte('"')
return b.String()
}
func clip(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
// resolveAllowed and blockedIP mirror the mail sender's reachability boundary exactly. They are duplicated
// rather than shared because the two packages have no other reason to depend on each other, and a boundary
// this important is better stated twice than reached for through an import that could later be weakened for
// an unrelated caller. Both are tested independently.
func resolveAllowed(host string) (net.IP, error) {
if ip := net.ParseIP(host); ip != nil {
if blockedIP(ip) {
return nil, fmt.Errorf("%w: %s", ErrBlockedHost, host)
}
return ip, nil
}
ips, err := net.LookupIP(host)
if err != nil {
return nil, fmt.Errorf("imap: resolve %s: %w", host, err)
}
for _, ip := range ips {
if !blockedIP(ip) {
return ip, nil
}
}
return nil, fmt.Errorf("%w: %s", ErrBlockedHost, host)
}
func blockedIP(ip net.IP) bool {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() || ip.IsMulticast() {
return true
}
if v4 := ip.To4(); v4 != nil {
switch {
case v4[0] == 169 && v4[1] == 254: // cloud instance metadata
return true
case v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127: // carrier grade NAT
return true
case v4[0] >= 240:
return true
}
return false
}
return len(ip) == net.IPv6len && ip[0]&0xfe == 0xfc
}