Smail/backend/config/validator.go
smail 9dc861d560
Some checks are pending
Security Checks / security (push) Waiting to run
角色权限收紧前保存证书直接引用与续期热切换改造
2026-09-09 17:42:19 +08:00

368 lines
11 KiB
Go

package config
import (
"errors"
"net"
"net/mail"
"path"
"path/filepath"
"regexp"
"strings"
)
var (
ErrInvalidSetupState = errors.New("invalid setup state")
ErrInvalidDatabase = errors.New("invalid database configuration")
ErrInvalidSMTP = errors.New("invalid SMTP configuration")
ErrInvalidSSL = errors.New("invalid SSL configuration")
ErrInvalidPlugin = errors.New("invalid plugin configuration")
ErrInvalidHTTP = errors.New("invalid HTTP configuration")
ErrInvalidEmail = errors.New("invalid email configuration")
ErrInvalidLogLevel = errors.New("invalid log level")
)
var logicalIDPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{1,63}$`)
var databaseLocalRefPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
func ValidateRootConfig(root RootConfig) error {
if root.ConfigVersion > CurrentConfigVersion || root.SchemaVersion <= 0 || root.SchemaVersion > CurrentSchemaVersion || root.Generation == 0 || !root.SetupState.Valid() {
return ErrInvalidSetupState
}
if !validLogLevel(root.LogLevel) {
return ErrInvalidLogLevel
}
if len(root.Sections) > 0 {
if err := ValidateSectionReferences(root.Sections, root.Generation); err != nil {
return err
}
}
if err := ValidateDatabaseConfig(root.Database); err != nil {
return err
}
if err := validateSMTP(root.SMTP); err != nil {
return err
}
if err := validateSSL(root.SSL); err != nil {
return err
}
if err := validateHTTP(root.HTTP); err != nil {
return err
}
if err := validateEmail(root.Email); err != nil {
return err
}
seen := make(map[string]struct{}, len(root.Plugins))
for _, plugin := range root.Plugins {
if err := validatePlugin(plugin); err != nil {
return err
}
if _, ok := seen[plugin.LogicalID]; ok {
return ErrInvalidPlugin
}
seen[plugin.LogicalID] = struct{}{}
}
if root.Security.PasswordVersion != "argon2id" {
return errors.New("only Argon2id password service is allowed")
}
return nil
}
func ValidateDatabaseConfig(database DatabaseConfig) error {
return validateDatabase(database)
}
func validateDatabase(database DatabaseConfig) error {
database = NormalizeDatabaseConfig(database)
switch database.Type {
case DatabaseTypeSQLite:
if !validSQLiteLocalRef(database.LocalRef) ||
filepath.IsAbs(database.LocalRef) ||
isWindowsAbsolute(database.LocalRef) ||
strings.ContainsAny(database.LocalRef, `\\:`) ||
database.Host != "" || database.Port != 0 || database.Username != "" ||
database.Password.Configured() || database.Password.Ciphertext != "" || database.Password.Version != 0 || !database.Password.UpdatedAt.IsZero() || database.Name != "" {
return ErrInvalidDatabase
}
case DatabaseTypeMySQL, DatabaseTypePostgreSQL:
if database.Host == "" || database.Username == "" || database.Name == "" || !database.Password.Configured() || database.LocalRef != "" || !validPort(int(database.Port)) {
return ErrInvalidDatabase
}
if err := ValidateStoredSecret(database.Password, true); err != nil {
return ErrInvalidDatabase
}
if net.ParseIP(database.Host) == nil && strings.ContainsAny(database.Host, `/\\ `) {
return ErrInvalidDatabase
}
default:
return ErrInvalidDatabase
}
return nil
}
func validSQLiteLocalRef(value string) bool {
value = filepath.ToSlash(value)
if value == "" || filepath.IsAbs(value) || isWindowsAbsolute(value) || strings.HasPrefix(value, "config/") || strings.ContainsAny(value, `\\:`) {
return false
}
parts := strings.Split(value, "/")
for _, part := range parts {
if part == "" || part == "." || part == ".." || !databaseLocalRefPattern.MatchString(part) {
return false
}
}
return true
}
func validateSMTP(smtp SMTPConfig) error {
if !validLoginVerificationPolicy(smtp.LoginPolicy) {
return ErrInvalidSMTP
}
if smtp.RelayHost != "" && !validSMTPHost(smtp.RelayHost) {
return ErrInvalidSMTP
}
if smtp.RelayPort != 0 && !validPort(smtp.RelayPort) {
return ErrInvalidSMTP
}
if smtp.FromDomain != "" && !validCanonicalDomain(smtp.FromDomain) {
return ErrInvalidSMTP
}
if strings.TrimSpace(smtp.Username) != smtp.Username {
return ErrInvalidSMTP
}
if err := ValidateStoredSecret(smtp.Password, false); err != nil {
return ErrInvalidSMTP
}
if smtp.From != "" {
parsedFrom, err := mail.ParseAddress(smtp.From)
if err != nil || parsedFrom.Address != smtp.From || strings.ContainsAny(smtp.From, "\r\n") {
return ErrInvalidSMTP
}
}
if smtp.Security != "" {
switch smtp.Security {
case "starttls", "implicit_tls", "tls":
case "plain":
if !isLocalSMTPHost(smtp.RelayHost) {
return ErrInvalidSMTP
}
default:
return ErrInvalidSMTP
}
}
relayConfigured := smtpRelayConfigured(smtp)
if !relayConfigured {
return nil
}
if smtp.RelayHost == "" || smtp.RelayPort == 0 || smtp.Security == "" || smtp.From == "" || smtp.FromDomain == "" {
return ErrInvalidSMTP
}
if smtp.Username == "" {
if smtp.Password.Configured() {
return ErrInvalidSMTP
}
} else if err := ValidateStoredSecret(smtp.Password, true); err != nil {
return ErrInvalidSMTP
}
return nil
}
// smtpRelayConfigured 只把实际连接或发件身份字段视为启用中继。
// 推荐端口、统一加密方式和默认发件域名单独存在时,中继仍保持可选状态。
func smtpRelayConfigured(smtp SMTPConfig) bool {
return smtp.RelayHost != "" || smtp.Username != "" || smtp.Password.Configured() || smtp.From != ""
}
func validateSSL(ssl SSLConfig) error {
if (ssl.PreviousCertificateRef == "") != (ssl.PreviousPrivateKeyRef == "") {
return ErrInvalidSSL
}
if ssl.PreviousCertificateRef != "" {
publicPath, err := resolveCustomSSLInput(".", ssl.PreviousCertificateRef)
privatePath, keyErr := resolveCustomSSLInput(".", ssl.PreviousPrivateKeyRef)
if err != nil || keyErr != nil || !isControlledRelativePath(ssl.PreviousCertificateRef) || !isControlledRelativePath(ssl.PreviousPrivateKeyRef) || !flatSSLPair(".", publicPath, privatePath) {
return ErrInvalidSSL
}
}
if !isControlledRelativePath(ssl.DefaultDirectory) {
return ErrInvalidSSL
}
if ssl.Management != "" && !ValidSSLManagement(ssl.Management) {
return ErrInvalidSSL
}
if ssl.Challenge != "" && ssl.Challenge != SSLChallengeHTTP && ssl.Challenge != SSLChallengeDNS {
return ErrInvalidSSL
}
activeDirectory := ssl.DefaultDirectory
switch ssl.Mode {
case ManagedSSLModeDefault, "default":
if ssl.DefaultDirectory == "" || ssl.CustomDirectory != "" {
return ErrInvalidSSL
}
case ManagedSSLModeUpload, "custom":
if !isControlledRelativePath(ssl.CustomDirectory) || ssl.CustomDirectory == "" {
return ErrInvalidSSL
}
activeDirectory = ssl.CustomDirectory
default:
return ErrInvalidSSL
}
if EffectiveSSLManagement(ssl) == SSLManagementCustom || EffectiveSSLManagement(ssl) == SSLManagementWebServer {
if (ssl.CertificateRef == "") != (ssl.PrivateKeyRef == "") {
return ErrInvalidSSL
}
for _, value := range []string{ssl.CertificateRef, ssl.PrivateKeyRef} {
if value != "" {
if _, err := resolveCustomSSLInput(".", value); err != nil {
return ErrInvalidSSL
}
}
}
return nil
}
if (ssl.CertificateRef != "" && !isControlledRelativePath(ssl.CertificateRef)) ||
(ssl.PrivateKeyRef != "" && !isControlledRelativePath(ssl.PrivateKeyRef)) {
return ErrInvalidSSL
}
if (ssl.CertificateRef == "") != (ssl.PrivateKeyRef == "") {
return ErrInvalidSSL
}
if ssl.CertificateRef != "" && (!isPathWithinDirectory(ssl.CertificateRef, activeDirectory) || !isPathWithinDirectory(ssl.PrivateKeyRef, activeDirectory)) {
return ErrInvalidSSL
}
return nil
}
func validateHTTP(http HTTPConfig) error {
if http.Port != 0 && !validPort(http.Port) {
return ErrInvalidHTTP
}
if http.HTTPSPort != 0 && !validPort(http.HTTPSPort) {
return ErrInvalidHTTP
}
if http.TLSMode != "" && http.TLSMode != HTTPSTLSModeDirect && http.TLSMode != HTTPSTLSModeProxy {
return ErrInvalidHTTP
}
if http.TLSMode == HTTPSTLSModeProxy && (http.HTTPSEnabled || http.HTTPSPort != 0) {
return ErrInvalidHTTP
}
for _, cidr := range http.TrustedProxyCIDRs {
if strings.TrimSpace(cidr) != cidr {
return ErrInvalidHTTP
}
if _, _, err := net.ParseCIDR(cidr); err != nil {
return ErrInvalidHTTP
}
}
if http.HTTPSEnabled && http.HTTPSPort == 0 {
return ErrInvalidHTTP
}
return nil
}
func validateEmail(email EmailConfig) error {
for _, port := range []int{email.SMTPPort, email.IMAPPort, email.POP3Port} {
if port != 0 && !validPort(port) {
return ErrInvalidEmail
}
}
if email.MaxMessageBytes < 0 || email.SpamFilterLevel < 0 || email.SpamFilterLevel > 3 || !validLoginVerificationPolicy(email.LoginVerificationPolicy) {
return ErrInvalidEmail
}
return nil
}
func isControlledRelativePath(value string) bool {
if value == "" || path.IsAbs(value) || filepath.IsAbs(value) || isWindowsAbsolute(value) || strings.ContainsAny(value, `\\:`) {
return false
}
cleaned := path.Clean(value)
if cleaned != value || cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return false
}
return true
}
func isPathWithinDirectory(value, directory string) bool {
if !isControlledRelativePath(value) || !isControlledRelativePath(directory) {
return false
}
return value != directory && strings.HasPrefix(value, directory+"/")
}
func validatePlugin(plugin PluginConfig) error {
if !logicalIDPattern.MatchString(plugin.LogicalID) || filepath.Base(plugin.FileName) != plugin.FileName || plugin.FileName == "" {
return ErrInvalidPlugin
}
if err := ValidateStoredSecret(plugin.Secret, false); err != nil {
return ErrInvalidPlugin
}
seenHosts := make(map[string]struct{}, len(plugin.NetworkAllowlist))
for _, host := range plugin.NetworkAllowlist {
normalized, ok := normalizeSMTPHost(host)
if !ok || net.ParseIP(normalized) != nil {
return ErrInvalidPlugin
}
if _, exists := seenHosts[normalized]; exists {
return ErrInvalidPlugin
}
seenHosts[normalized] = struct{}{}
}
return nil
}
func validLogLevel(value string) bool {
switch value {
case "", "debug", "info", "warn", "error":
return true
default:
return false
}
}
func validLoginVerificationPolicy(value string) bool {
switch value {
case "", "off", "always", "new_device":
return true
default:
return false
}
}
func validCanonicalDomain(value string) bool {
normalized, err := normalizeConfigDomain(value)
return err == nil && normalized == value
}
func validSMTPHost(value string) bool {
_, ok := normalizeSMTPHost(value)
return ok
}
func normalizeSMTPHost(value string) (string, bool) {
if value == "" || strings.TrimSpace(value) != value || strings.ContainsAny(value, "@/\\\r\n\t ") {
return "", false
}
if parsed := net.ParseIP(value); parsed != nil {
return parsed.String(), true
}
normalized, err := normalizeConfigDomain(value)
if err != nil || normalized != value {
return "", false
}
return normalized, true
}
func isLocalSMTPHost(value string) bool {
normalized, ok := normalizeSMTPHost(value)
if !ok {
return false
}
if normalized == "localhost" {
return true
}
parsed := net.ParseIP(normalized)
return parsed != nil && parsed.IsLoopback()
}
func validPort(port int) bool { return port > 0 && port <= 65535 }