From e9fbbe3373eb66a345f5e3829e2563b94dc92051 Mon Sep 17 00:00:00 2001 From: Gab Virebent Date: Mon, 24 Aug 2026 17:34:45 +0200 Subject: Harden transport and preserve profile identities --- internal/config/config.go | 64 +++++++++++++++++ internal/config/config_test.go | 151 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 internal/config/config_test.go (limited to 'internal/config') diff --git a/internal/config/config.go b/internal/config/config.go index 5ee60ea..faf2a93 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "net" + "net/mail" "os" "strconv" "strings" @@ -23,11 +24,16 @@ type SMTPConfig struct { Port int Recipient string EnvelopeFrom string + Sender string + Username string + Password string + PasswordFile string HELO string TLSServerName string RequireTLS bool ImplicitTLS bool Timeout time.Duration + ProbeInterval time.Duration DryRun bool } @@ -55,6 +61,8 @@ type SecurityConfig struct { MessageIDDomain string IdenticonsCLI string RequireFace bool + CSRFKeyFile string + CSRFKey []byte } func Load() (Config, error) { @@ -66,11 +74,15 @@ func Load() (Config, error) { Port: envInt("N2U_SMTP_PORT", "M2U_SMTP_PORT", 25), Recipient: env("N2U_SMTP_RECIPIENT", "M2U_SMTP_RECIPIENT", "mail2news@mail2news.tcpreset.net"), EnvelopeFrom: env("N2U_SMTP_ENVELOPE_FROM", "M2U_SMTP_ENVELOPE_FROM", "n2usenet@virebent.art"), + Sender: envAny("n2usenet@virebent.art", "N2U_SMTP_SENDER", "M2U_SMTP_SENDER", "N2U_SMTP_HEADER_FROM", "M2U_SMTP_HEADER_FROM"), + Username: env("N2U_SMTP_USERNAME", "M2U_SMTP_USERNAME", ""), + PasswordFile: env("N2U_SMTP_PASSWORD_FILE", "M2U_SMTP_PASSWORD_FILE", ""), HELO: env("N2U_SMTP_HELO", "M2U_SMTP_HELO", "n2usenet.virebent.art"), TLSServerName: env("N2U_SMTP_TLS_SERVER_NAME", "M2U_SMTP_TLS_SERVER_NAME", "mail.tcpreset.net"), RequireTLS: envBool("N2U_SMTP_REQUIRE_TLS", "M2U_SMTP_REQUIRE_TLS", true), ImplicitTLS: envBool("N2U_SMTP_IMPLICIT_TLS", "M2U_SMTP_IMPLICIT_TLS", false), Timeout: envDuration("N2U_SMTP_TIMEOUT", "M2U_SMTP_TIMEOUT", 90*time.Second), + ProbeInterval: envDuration("N2U_TRANSPORT_PROBE_INTERVAL", "M2U_TRANSPORT_PROBE_INTERVAL", 15*time.Minute), DryRun: envBool("N2U_DRY_RUN", "M2U_DRY_RUN", false), }, Nym: NymConfig{ @@ -96,8 +108,23 @@ func Load() (Config, error) { MessageIDDomain: env("N2U_MESSAGE_ID_DOMAIN", "M2U_MESSAGE_ID_DOMAIN", "n2usenet.virebent.art"), IdenticonsCLI: env("N2U_IDENTICONS_CLI", "M2U_IDENTICONS_CLI", "/usr/local/bin/identicons-cli"), RequireFace: envBool("N2U_REQUIRE_FACE", "M2U_REQUIRE_FACE", true), + CSRFKeyFile: env("N2U_CSRF_KEY_FILE", "M2U_CSRF_KEY_FILE", ""), }, } + if cfg.SMTP.PasswordFile != "" { + password, err := readSecretFile(cfg.SMTP.PasswordFile) + if err != nil { + return Config{}, fmt.Errorf("read SMTP password file: %w", err) + } + cfg.SMTP.Password = password + } + if cfg.Security.CSRFKeyFile != "" { + key, err := readSecretFile(cfg.Security.CSRFKeyFile) + if err != nil { + return Config{}, fmt.Errorf("read CSRF key file: %w", err) + } + cfg.Security.CSRFKey = []byte(key) + } if err := cfg.Validate(); err != nil { return Config{}, err } @@ -120,9 +147,22 @@ func (c Config) Validate() error { if c.SMTP.EnvelopeFrom == "" { return fmt.Errorf("N2U_SMTP_ENVELOPE_FROM is required") } + sender, err := mail.ParseAddress(c.SMTP.Sender) + if err != nil || sender.Address == "" { + return fmt.Errorf("N2U_SMTP_SENDER must be a valid email address") + } + if (c.SMTP.Username == "") != (c.SMTP.Password == "") { + return fmt.Errorf("N2U_SMTP_USERNAME and N2U_SMTP_PASSWORD_FILE must be configured together") + } + if c.SMTP.Username != "" && !c.SMTP.RequireTLS && !c.SMTP.ImplicitTLS { + return fmt.Errorf("SMTP authentication requires TLS") + } if c.SMTP.RequireTLS && c.SMTP.TLSServerName == "" { return fmt.Errorf("N2U_SMTP_TLS_SERVER_NAME is required when TLS is required") } + if c.SMTP.ProbeInterval < time.Minute { + return fmt.Errorf("N2U_TRANSPORT_PROBE_INTERVAL must be at least 1m") + } if c.Nym.Enabled { if _, _, err := net.SplitHostPort(c.Nym.SocksAddr); err != nil { return fmt.Errorf("invalid N2U_NYM_SOCKS: %w", err) @@ -146,6 +186,9 @@ func (c Config) Validate() error { if c.Security.RequireFace && c.Security.IdenticonsCLI == "" { return fmt.Errorf("N2U_IDENTICONS_CLI is required when N2U_REQUIRE_FACE=true") } + if c.Security.CSRFKeyFile != "" && len(c.Security.CSRFKey) < 32 { + return fmt.Errorf("N2U_CSRF_KEY_FILE must contain at least 32 bytes") + } return nil } @@ -157,6 +200,15 @@ func env(primary, legacy, fallback string) string { return v } +func envAny(fallback string, keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + } + return fallback +} + func envBool(primary, legacy string, fallback bool) bool { v, ok := envValue(primary, legacy) if !ok { @@ -207,3 +259,15 @@ func envValue(primary, legacy string) (string, bool) { } return "", false } + +func readSecretFile(path string) (string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return "", err + } + value := strings.TrimSpace(string(raw)) + if value == "" { + return "", fmt.Errorf("file is empty") + } + return value, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..fdb02e4 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,151 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func validConfig() Config { + return Config{ + Listen: "127.0.0.1:8095", + SMTP: SMTPConfig{ + Host: "mail.virebent.art", + Port: 587, + Recipient: "mail2news@mail2news.tcpreset.net", + EnvelopeFrom: "n2usenet@virebent.art", + Sender: "n2usenet@virebent.art", + TLSServerName: "mail.virebent.art", + RequireTLS: true, + ProbeInterval: 15 * time.Minute, + }, + Security: SecurityConfig{ + MinHashcashBits: 20, + MinMessageBytes: 10, + MaxMessageBytes: 65536, + MaxNewsgroups: 3, + RateLimitCount: 10, + RateLimitWindow: time.Hour, + IdenticonsCLI: "/usr/local/bin/identicons-cli", + RequireFace: true, + }, + } +} + +func TestLoadAcceptsLegacyHeaderFromAsSender(t *testing.T) { + t.Setenv("N2U_SMTP_HEADER_FROM", "legacy-sender@virebent.art") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + if got, want := cfg.SMTP.Sender, "legacy-sender@virebent.art"; got != want { + t.Fatalf("Sender = %q, want %q", got, want) + } +} + +func TestLoadPrefersSenderOverLegacyHeaderFrom(t *testing.T) { + t.Setenv("N2U_SMTP_SENDER", "sender@virebent.art") + t.Setenv("N2U_SMTP_HEADER_FROM", "legacy-sender@virebent.art") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + if got, want := cfg.SMTP.Sender, "sender@virebent.art"; got != want { + t.Fatalf("Sender = %q, want %q", got, want) + } +} + +func TestValidateSMTPAuthentication(t *testing.T) { + tests := []struct { + name string + mutate func(*Config) + wantErr string + }{ + { + name: "credentials with STARTTLS", + mutate: func(cfg *Config) { + cfg.SMTP.Username = "n2usenet@virebent.art" + cfg.SMTP.Password = "test-password" + }, + }, + { + name: "missing password", + mutate: func(cfg *Config) { + cfg.SMTP.Username = "n2usenet@virebent.art" + }, + wantErr: "must be configured together", + }, + { + name: "missing username", + mutate: func(cfg *Config) { + cfg.SMTP.Password = "test-password" + }, + wantErr: "must be configured together", + }, + { + name: "credentials without TLS", + mutate: func(cfg *Config) { + cfg.SMTP.Username = "n2usenet@virebent.art" + cfg.SMTP.Password = "test-password" + cfg.SMTP.RequireTLS = false + }, + wantErr: "requires TLS", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := validConfig() + tt.mutate(&cfg) + err := cfg.Validate() + if tt.wantErr == "" { + if err != nil { + t.Fatalf("Validate returned error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Validate error = %v, want substring %q", err, tt.wantErr) + } + }) + } +} + +func TestLoadReadsSMTPPasswordFile(t *testing.T) { + passwordPath := filepath.Join(t.TempDir(), "smtp-password") + if err := os.WriteFile(passwordPath, []byte("test-password\n"), 0600); err != nil { + t.Fatalf("write password file: %v", err) + } + t.Setenv("N2U_SMTP_USERNAME", "n2usenet@virebent.art") + t.Setenv("N2U_SMTP_PASSWORD_FILE", passwordPath) + t.Setenv("N2U_SMTP_REQUIRE_TLS", "true") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + if got, want := cfg.SMTP.Password, "test-password"; got != want { + t.Fatalf("Password = %q, want %q", got, want) + } +} + +func TestLoadReadsPersistentCSRFKey(t *testing.T) { + keyPath := filepath.Join(t.TempDir(), "csrf-key") + want := strings.Repeat("k", 32) + if err := os.WriteFile(keyPath, []byte(want+"\n"), 0600); err != nil { + t.Fatalf("write CSRF key file: %v", err) + } + t.Setenv("N2U_CSRF_KEY_FILE", keyPath) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + if got := string(cfg.Security.CSRFKey); got != want { + t.Fatalf("CSRFKey = %q, want %q", got, want) + } +} -- cgit v1.2.3