Add optional inbound relay (backup-MX) behind INBOUND_RELAY_ENABLE.
test / test (push) Waiting to run
test / test (push) Waiting to run
Port 25 accepts only configured domains and listed recipients, then forwards to an upstream; the outbound path is unchanged when the flag is off. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,6 +3,8 @@ package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
@@ -146,3 +148,104 @@ func Email(addr string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const maxHostLen = 253
|
||||
|
||||
// NormalizeHost trims, lower-cases, and strips wrapping IPv6 brackets so the
|
||||
// stored value is a bare hostname or IP, safe to wrap again when writing maps.
|
||||
func NormalizeHost(host string) string {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
host = host[1 : len(host)-1]
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// Host enforces a whitelist for an upstream hostname or IP (security.md): a
|
||||
// dotted domain, a single DNS label (LAN names), or an IPv4/IPv6 address.
|
||||
func Host(host string) error {
|
||||
if host == "" {
|
||||
return fmt.Errorf("host is required")
|
||||
}
|
||||
if len(host) > maxHostLen {
|
||||
return fmt.Errorf("host must be at most %d characters", maxHostLen)
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return nil
|
||||
}
|
||||
labels := strings.Split(host, ".")
|
||||
for _, label := range labels {
|
||||
if err := domainLabel(label); err != nil {
|
||||
return fmt.Errorf("host is invalid: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Port checks a TCP port number parsed from form input.
|
||||
func Port(raw string) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("port is required")
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
return 0, fmt.Errorf("port must be between 1 and 65535")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// TLSMode checks a Postfix smtp_tls_policy_maps level.
|
||||
func TLSMode(mode string) error {
|
||||
switch mode {
|
||||
case "may", "encrypt", "none":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid TLS mode")
|
||||
}
|
||||
}
|
||||
|
||||
// RecipientMode checks an inbound-domain recipient policy.
|
||||
func RecipientMode(mode string) error {
|
||||
switch mode {
|
||||
case "list", "any":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid recipient mode")
|
||||
}
|
||||
}
|
||||
|
||||
// MailboxInDomain checks that addr is a conservative mailbox on domain
|
||||
// (security.md). domain must already be normalised.
|
||||
func MailboxInDomain(addr, domain string) error {
|
||||
at := strings.LastIndexByte(addr, '@')
|
||||
if at <= 0 || at >= len(addr)-1 {
|
||||
return fmt.Errorf("%q is not a valid email address", addr)
|
||||
}
|
||||
local, host := addr[:at], addr[at+1:]
|
||||
if host != domain {
|
||||
return fmt.Errorf("%q does not belong to domain %s", addr, domain)
|
||||
}
|
||||
if err := mailboxLocalPart(local); err != nil {
|
||||
return fmt.Errorf("%q: %w", addr, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mailboxLocalPart(local string) error {
|
||||
if local == "" {
|
||||
return fmt.Errorf("missing the part before '@'")
|
||||
}
|
||||
if local[0] == '.' || local[len(local)-1] == '.' {
|
||||
return fmt.Errorf("local part must not start or end with '.'")
|
||||
}
|
||||
for i := 0; i < len(local); i++ {
|
||||
c := local[i]
|
||||
lower := c >= 'a' && c <= 'z'
|
||||
digit := c >= '0' && c <= '9'
|
||||
if !lower && !digit && c != '.' && c != '-' && c != '_' && c != '+' {
|
||||
return fmt.Errorf("local part may contain only lower-case letters, digits, '.', '-', '_' and '+'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -65,3 +65,69 @@ func TestValidateDomainLongLabelRejected(t *testing.T) {
|
||||
t.Error("expected error for over-long label")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostValid(t *testing.T) {
|
||||
valid := []string{
|
||||
"10.0.0.8",
|
||||
"192.0.2.20",
|
||||
"2001:db8::1",
|
||||
"mail.internal.example",
|
||||
"mx1",
|
||||
"mail-1.lan",
|
||||
}
|
||||
for _, h := range valid {
|
||||
if err := Host(h); err != nil {
|
||||
t.Errorf("Host(%q) unexpected error: %v", h, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostInvalid(t *testing.T) {
|
||||
invalid := []string{
|
||||
"",
|
||||
"exa mple",
|
||||
"host/name",
|
||||
"host;rm",
|
||||
"-bad",
|
||||
"bad-",
|
||||
"host\nname",
|
||||
}
|
||||
for _, h := range invalid {
|
||||
if err := Host(h); err == nil {
|
||||
t.Errorf("Host(%q) = nil, want error", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHostStripsIPv6Brackets(t *testing.T) {
|
||||
if got := NormalizeHost(" [2001:DB8::1] "); got != "2001:db8::1" {
|
||||
t.Errorf("NormalizeHost IPv6 = %q", got)
|
||||
}
|
||||
if got := NormalizeHost("Mail.Example.COM"); got != "mail.example.com" {
|
||||
t.Errorf("NormalizeHost hostname = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPort(t *testing.T) {
|
||||
n, err := Port("25")
|
||||
if err != nil || n != 25 {
|
||||
t.Fatalf("Port(25) = %d, %v", n, err)
|
||||
}
|
||||
for _, raw := range []string{"", "0", "65536", "abc", "-1"} {
|
||||
if _, err := Port(raw); err == nil {
|
||||
t.Errorf("Port(%q) = nil, want error", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailboxInDomain(t *testing.T) {
|
||||
if err := MailboxInDomain("staff@lists.example.com", "lists.example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MailboxInDomain("staff@other.com", "lists.example.com"); err == nil {
|
||||
t.Fatal("expected domain mismatch error")
|
||||
}
|
||||
if err := MailboxInDomain("bad addr@lists.example.com", "lists.example.com"); err == nil {
|
||||
t.Fatal("expected local-part error")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user