mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
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
240 lines
9.0 KiB
Go
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
|
|
}
|