Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/platform/mailer/mailer.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/platform/mailer/mailer.go |
| Original SHA-256 | e516f615f325df9fc4f148d619ad892d8855a27bc58fe4ed2de77a621235a828 |
| Git blob SHA | 52237014a1e56163462176b006a6cc7478e649ce |
| Reading-copy SHA-256 | e516f615f325df9fc4f148d619ad892d8855a27bc58fe4ed2de77a621235a828 |
Recovered source transcript
Download complete source text ↓268 lines · 10.8 KB
// Package mailer sends mail through an ORGANIZATION'S OWN mail server, using credentials that organization
// supplied. That single sentence is the whole security problem: an authenticated customer is handing this
// server a host name and asking it to open a connection, which is a server side request forgery primitive
// unless it is bounded. Everything defensive in this file exists for that reason.
//
// THE FOUR BOUNDS.
//
// 1. THE DESTINATION IS RESOLVED AND CHECKED BEFORE ANYTHING IS DIALLED, and the connection is then made to
// the address that was checked, not to the name. Checking the name and dialling the name leaves a window
// where DNS answers differently the second time, which is the classic rebinding bypass. Loopback, private
// ranges, link local and the cloud metadata address are all refused, so this cannot be pointed at the
// inside of the network it runs in.
// 2. TRANSPORT SECURITY IS MANDATORY. There is no plaintext mode at any port. What travels here is an
// activation credential and a person's name.
// 3. AUTHENTICATION IS PLAIN OVER TLS ONLY. CRAM MD5, which net/smtp also offers, is MD5 and sits outside
// the FIPS validated boundary this platform is built inside. PlainAuth additionally refuses to hand
// credentials to a server that has not established TLS, which is a second lock on the first bound.
// 4. EVERY HEADER VALUE IS CHECKED FOR CARRIAGE RETURN AND LINE FEED. A display name carrying a newline is
// how a header injection becomes a blind carbon copy to an attacker.
package mailer
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/smtp"
"strconv"
"github.com/missionworkforce/missionmatch/internal/platform/storage"
"strings"
"time"
)
// Config is one organization's transport, assembled by the caller from settings plus the opened secret. The
// password arrives as a value and is never held anywhere in this package beyond the call.
type Config struct {
Host string
Port int
Username string
Password string
Security string // starttls or implicit_tls
From string
FromName string
ReplyTo string
}
// Message is what to send. Body is plain text, deliberately: an invite is four lines and a link, and an HTML
// mail path would mean escaping user supplied names into markup for no gain a customer would notice.
type Message struct {
To string
Subject string
Body string
// Calendar is an optional RFC 5545 object. When present the message is sent as multipart/alternative
// with a text/calendar part, which is how every mainstream calendar client recognises an invitation.
//
// IT IS ALTERNATIVE, NOT MIXED, and the distinction is the whole reason invitations work. multipart/mixed
// produces a message with a file attached, which a calendar ignores and a person has to open by hand;
// multipart/alternative with method=REQUEST is what makes the accept and decline buttons appear.
Calendar string
// CalendarMethod is REQUEST for a new or changed meeting and CANCEL to withdraw one. It must agree with
// the METHOD inside the object, or clients disagree about what the message means.
CalendarMethod string
}
var (
ErrBlockedHost = errors.New("mailer: that mail host resolves to an address inside this network and is refused")
ErrHeader = errors.New("mailer: a mail header value contained a line break and was refused")
)
// dialTimeout bounds the whole exchange. A customer typing a host that silently drops packets must not hold a
// request handler open, so this is short enough that a person watching a settings screen still gets an answer.
const dialTimeout = 12 * time.Second
// Send delivers one message. It returns a transport error suitable for showing to an administrator, having
// been scrubbed of credentials by the caller before it is stored.
func Send(ctx context.Context, cfg Config, msg Message) error {
for _, v := range []string{cfg.From, cfg.FromName, cfg.ReplyTo, msg.To, msg.Subject} {
if strings.ContainsAny(v, "\r\n") {
return ErrHeader
}
}
if cfg.Host == "" || cfg.Port <= 0 {
return errors.New("mailer: no mail host is configured")
}
if cfg.From == "" {
return errors.New("mailer: no sender address is configured")
}
ip, err := resolveAllowed(ctx, cfg.Host)
if err != nil {
return 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. MinVersion is pinned rather than left to the default so this
// stays explicit under review.
tlsCfg := &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}
d := &net.Dialer{Timeout: dialTimeout}
var conn net.Conn
if cfg.Security == "implicit_tls" {
conn, err = tls.DialWithDialer(d, "tcp", addr, tlsCfg)
} else {
conn, err = d.DialContext(ctx, "tcp", addr)
}
if err != nil {
return fmt.Errorf("mailer: connect to %s: %w", cfg.Host, err)
}
defer conn.Close()
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
} else {
_ = conn.SetDeadline(time.Now().Add(dialTimeout * 3))
}
c, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
return fmt.Errorf("mailer: greet %s: %w", cfg.Host, err)
}
defer func() { _ = c.Quit() }()
if cfg.Security != "implicit_tls" {
ok, _ := c.Extension("STARTTLS")
if !ok {
// FAILS CLOSED. A server that will not upgrade does not get the credentials, and the operator is
// told exactly why rather than being left with a generic authentication failure.
return fmt.Errorf("mailer: %s does not offer STARTTLS and this platform will not send mail in the clear", cfg.Host)
}
if err := c.StartTLS(tlsCfg); err != nil {
return fmt.Errorf("mailer: start TLS with %s: %w", cfg.Host, err)
}
}
if cfg.Username != "" {
// PlainAuth carries its own refusal to authenticate over an unencrypted link, so this is the second
// independent guard on the same property rather than a duplicate of the first.
if err := c.Auth(smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)); err != nil {
return fmt.Errorf("mailer: authenticate to %s: %w", cfg.Host, err)
}
}
if err := c.Mail(cfg.From); err != nil {
return fmt.Errorf("mailer: sender rejected: %w", err)
}
if err := c.Rcpt(msg.To); err != nil {
return fmt.Errorf("mailer: recipient rejected: %w", err)
}
wc, err := c.Data()
if err != nil {
return fmt.Errorf("mailer: begin message: %w", err)
}
if _, err := wc.Write([]byte(compose(cfg, msg))); err != nil {
_ = wc.Close()
return fmt.Errorf("mailer: write message: %w", err)
}
if err := wc.Close(); err != nil {
return fmt.Errorf("mailer: deliver message: %w", err)
}
return nil
}
func compose(cfg Config, msg Message) string {
var b strings.Builder
from := cfg.From
if cfg.FromName != "" {
from = fmt.Sprintf("%q <%s>", cfg.FromName, cfg.From)
}
b.WriteString("From: " + from + "\r\n")
b.WriteString("To: " + msg.To + "\r\n")
if cfg.ReplyTo != "" {
b.WriteString("Reply-To: " + cfg.ReplyTo + "\r\n")
}
b.WriteString("Subject: " + msg.Subject + "\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
if msg.Calendar != "" {
method := msg.CalendarMethod
if method == "" {
method = "REQUEST"
}
// A fixed boundary would be a bug the day a body legitimately contained it. This one is derived from
// the content, so it cannot collide with the content.
boundary := "mmboundary" + storage.SHA256Hex([]byte(msg.Subject + msg.To))[:24]
b.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n")
b.WriteString("Auto-Submitted: auto-generated\r\n")
b.WriteString("\r\n")
b.WriteString("--" + boundary + "\r\n")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
b.WriteString(dotStuff(msg.Body))
b.WriteString("\r\n--" + boundary + "\r\n")
b.WriteString("Content-Type: text/calendar; charset=utf-8; method=" + method + "\r\n")
b.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n")
b.WriteString(dotStuff(msg.Calendar))
b.WriteString("\r\n--" + boundary + "--\r\n")
return b.String()
}
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
// Auto-Submitted marks this as machine generated so a recipient's out of office does not answer it, and
// so mailing list software does not treat it as human correspondence. RFC 3834.
b.WriteString("Auto-Submitted: auto-generated\r\n")
b.WriteString("\r\n")
b.WriteString(dotStuff(msg.Body))
b.WriteString("\r\n")
return b.String()
}
// dotStuff escapes a line beginning with a single dot, which would otherwise terminate the DATA phase early
// and truncate the message at exactly the point an attacker chooses. net/smtp does not do this for us, and a
// calendar object is full of lines that a fold could start with anything.
func dotStuff(s string) string {
return strings.ReplaceAll(s, "\n.", "\n..")
}
// resolveAllowed turns a host name into ONE address that is safe to dial, or refuses. It returns the first
// allowed address rather than all of them because the connection is made to exactly what was checked.
func resolveAllowed(ctx context.Context, host string) (net.IP, error) {
// A literal address skips resolution but is checked identically. Passing an IP directly must not be a way
// around the guard.
if ip := net.ParseIP(host); ip != nil {
if blockedIP(ip) {
return nil, fmt.Errorf("%w: %s", ErrBlockedHost, host)
}
return ip, nil
}
rctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
ips, err := net.DefaultResolver.LookupIP(rctx, "ip", host)
if err != nil {
return nil, fmt.Errorf("mailer: resolve %s: %w", host, err)
}
for _, ip := range ips {
if !blockedIP(ip) {
return ip, nil
}
}
return nil, fmt.Errorf("%w: %s", ErrBlockedHost, host)
}
// blockedIP is the reachability boundary. Everything here is an address that a mail server has no business
// living at and that an attacker would very much like this process to connect to on their behalf.
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 {
// The cloud instance metadata address. It is link local and therefore already caught above; it is
// named separately because it is the single most valuable target of this class of bug and a future
// edit to the line above must not silently drop it.
case v4[0] == 169 && v4[1] == 254:
return true
// Carrier grade NAT, 100.64.0.0/10. Not private by the standard library's definition, and it is where
// this operator's own machines live.
case v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127:
return true
// Reserved, 240.0.0.0/4, and the broadcast address.
case v4[0] >= 240:
return true
}
return false
}
// IPv6 unique local addresses, fc00::/7, the equivalent of a private range.
return len(ip) == net.IPv6len && ip[0]&0xfe == 0xfc
}