Files
BaaRai e233c02314 Translate all Chinese text to English
Comments, doc comments, UI labels, log and error messages, LLM prompt
strings, markdown docs, test data and assertions. Identifiers, enum
keys, routes, placeholders and code structure are unchanged.

Notes:
- docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md
- tests that exercised CJK specific handling (GBK zip names, Chinese
  full text search) now use ASCII equivalents; go build passes and the
  test suite matches the pre translation baseline (remaining failures
  need a PostgreSQL DSN and fail identically on the original)
- frontend mention syntax now uses English kinds (finding, asset, ...)
  matching the backend, chat mentions tests pass
2026-10-08 23:14:34 +02:00

240 lines
9.0 KiB
Go

package notify
import (
"context"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"mime"
"net"
"net/smtp"
"strconv"
"strings"
"time"
)
// emailDialTimeout / emailSessionTimeout bound connection setup and the whole
// SMTP session respectively. net/smtp has no timeout mechanism of its own;
// without these two, a stuck peer would hang the delivery goroutine forever —
// and since the dispatcher is a single goroutine processing serially, that
// stalls the entire notification system.
const (
emailDialTimeout = 10 * time.Second
emailSessionTimeout = 45 * time.Second
)
// emailChannel implements SMTP email delivery.
type emailChannel struct{}
func (emailChannel) Kind() string { return KindEmail }
// Email has no platform rate limit, but it should not be used to spam; give a
// lenient default.
func (emailChannel) DefaultRatePerMin() int { return 60 }
// Only the password is masked. SMTP host, account, and recipients are not
// secrets; masking them would only make editing harder.
func (emailChannel) SecretKeys() []string { return []string{"password"} }
// host/port decide which server the password is handed to; tls decides whether
// the transport is encrypted. Any change to the three requires re-affirming the
// password — which also makes "turn off TLS" a step that must explicitly carry
// the credential rather than a casual edit.
func (emailChannel) DestinationKeys() []string { return []string{"host", "port", "tls"} }
func (emailChannel) Validate(cfg map[string]any) error {
if cfgString(cfg, "host") == "" {
return errors.New("missing SMTP server address")
}
port := cfgInt(cfg, "port")
if port <= 0 || port > 65535 {
return errors.New("invalid SMTP port (should be 1-65535)")
}
if cfgString(cfg, "from") == "" {
return errors.New("missing sender address")
}
if len(cfgStrings(cfg, "to")) == 0 {
return errors.New("at least one recipient address is required")
}
return nil
}
func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
if err := c.Validate(cfg); err != nil {
return 0, Permanent(err)
}
host := cfgString(cfg, "host")
port := cfgInt(cfg, "port")
from := cfgString(cfg, "from")
to := cfgStrings(cfg, "to")
username := cfgString(cfg, "username")
password := cfgString(cfg, "password")
implicitTLS := cfgBool(cfg, "tls")
msg, err := buildEmailMessage(from, to, m)
if err != nil {
return 0, Permanent(err)
}
addr := net.JoinHostPort(host, strconv.Itoa(port))
client, err := emailDial(ctx, addr, host, implicitTLS)
if err != nil {
return 0, err
}
defer client.Close()
// STARTTLS: upgrade when the peer supports it. Credentials must not be sent
// on a plaintext session (see the auth note below).
if !implicitTLS {
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
return 0, fmt.Errorf("STARTTLS failed: %w", err)
}
}
}
if username != "" {
if err := client.Auth(smtp.PlainAuth("", username, password, host)); err != nil {
// smtp.PlainAuth refuses to send credentials over an unencrypted
// connection (unless the target is localhost). That is **correct**
// security behavior and must not be bypassed, but the reason needs to
// be spelled out — otherwise the user just sees "unencrypted
// connection" with no idea what to do.
if strings.Contains(err.Error(), "unencrypted connection") {
return 0, Permanent(fmt.Errorf("refusing to send credentials: the connection is not encrypted. Enable TLS, or use port 465 (implicit TLS), or tick the \"Enable TLS\" checkbox (%w)", err))
}
return 0, Permanent(fmt.Errorf("SMTP authentication failed: %w", err))
}
}
if err := client.Mail(from); err != nil {
return 0, smtpStageError(fmt.Sprintf("sender %s rejected", from), err)
}
for _, rcpt := range to {
if err := client.Rcpt(rcpt); err != nil {
return 0, smtpStageError(fmt.Sprintf("recipient %s rejected", rcpt), err)
}
}
w, err := client.Data()
if err != nil {
return 0, fmt.Errorf("SMTP DATA failed: %w", err)
}
if _, err := w.Write([]byte(msg)); err != nil {
return 0, fmt.Errorf("failed to write the email body: %w", err)
}
if err := w.Close(); err != nil {
return 0, fmt.Errorf("failed to submit the email: %w", err)
}
// A Quit failure does not change the fact that "the server has accepted the
// email", so its error is ignored.
_ = client.Quit()
// Email has no length truncation (the full HTML body is sent); the whole
// batch counts as delivered.
return len(m.Items), nil
}
// emailDial establishes the SMTP connection.
//
// implicitTLS=true uses the port-465 style "TLS on connect"; false uses 25/587
// plaintext connect followed by STARTTLS. The two must not be mixed: sending a
// plaintext greeting to port 465 gets disconnected immediately.
//
// The session deadline is set **at connect time** (not patched on afterwards),
// because net/smtp's Client hides the underlying connection in unexported
// fields that callers cannot reach; once handed over, the preset deadline is
// the only fallback. This also covers blocking during the handshake.
// Control hooks blockInternalDial, sharing the same guard as the HTTP channels.
// Without it SMTP would be the gap in the whole SSRF defense: host set to
// 169.254.169.254 or 127.0.0.1 would connect directly, and when the
// smtp.NewClient handshake fails it wraps the peer's reply line into the error,
// which is echoed via last_error by the delivery-history API — a semi-blind
// read primitive; the timing difference between "connection refused vs timeout"
// can also be used to probe ports. The dial phase is the final enforcement
// point and also covers DNS rebinding.
func emailDial(ctx context.Context, addr, host string, implicitTLS bool) (*smtp.Client, error) {
d := &net.Dialer{Timeout: emailDialTimeout, Control: blockInternalDial}
var conn net.Conn
var err error
if implicitTLS {
conn, err = tls.DialWithDialer(d, "tcp", addr, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12})
} else {
conn, err = d.DialContext(ctx, "tcp", addr)
}
if err != nil {
return nil, fmt.Errorf("failed to connect to the SMTP server: %w", err)
}
_ = conn.SetDeadline(time.Now().Add(emailSessionTimeout))
client, err := smtp.NewClient(conn, host)
if err != nil {
conn.Close()
return nil, fmt.Errorf("SMTP handshake failed: %w", err)
}
return client, nil
}
// smtpStageError classifies a stage's failure as "retryable" or "permanent" by
// the SMTP reply code.
//
// Why the distinction matters: SMTP 4xx and 5xx have completely different
// semantics —
// - 4xx (450 greylisting, 451 local error, 452 insufficient storage) is a
// **temporary** refusal; the proper practice is to retry later. Greylisting
// in particular hits almost every first delivery attempt.
// - 5xx (550 user does not exist, 553 invalid address) is a permanent
// refusal; retrying is pointless.
//
// If everything were judged permanent, a greylisting mail server would drop
// **every** push into failed after the first attempt — exactly the scenario
// where automatic retry should shine.
// The reply code is the first three digits of the error text; when no code can
// be extracted it is treated as retryable (better one extra try than condemning
// a possibly transient failure just because it cannot be parsed).
func smtpStageError(what string, err error) error {
code := smtpReplyCode(err.Error())
if code >= 500 && code < 600 {
return Permanent(fmt.Errorf("%s: %w", what, err))
}
return fmt.Errorf("%s: %w", what, err)
}
// smtpReplyCode extracts the leading three-digit reply code from SMTP error
// text, returning 0 when there is none. net/smtp does not export the error code
// field, so it can only be read from the text; the format is "450 4.7.1 ...".
func smtpReplyCode(text string) int {
if len(text) < 3 {
return 0
}
n, err := strconv.Atoi(text[:3])
if err != nil {
return 0
}
return n
}
// buildEmailMessage assembles the complete RFC 5322 email.
//
// The body is base64-encoded for two reasons: first, SMTP limits a single line
// to 1000 bytes, and HTML bodies (especially digest emails) easily produce
// over-long lines; second, base64 never produces a line starting with ".",
// avoiding SMTP dot-stuffing entirely.
func buildEmailMessage(from string, to []string, m Message) (string, error) {
var b strings.Builder
fmt.Fprintf(&b, "From: %s\r\n", from)
fmt.Fprintf(&b, "To: %s\r\n", strings.Join(to, ", "))
// A non-ASCII subject must be RFC 2047 encoded, otherwise clients display it
// as mojibake.
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", htmlTitle(m)))
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n")
b.WriteString("Content-Transfer-Encoding: base64\r\n")
// Email has no hard length limit, so the body is not truncated.
b.WriteString("\r\n")
encoded := base64.StdEncoding.EncodeToString([]byte(htmlBody(m, 0)))
// base64 is wrapped at 76 characters per RFC 2045.
for len(encoded) > 76 {
b.WriteString(encoded[:76] + "\r\n")
encoded = encoded[76:]
}
b.WriteString(encoded + "\r\n")
return b.String(), nil
}