* HH-547: allow cross-origin widget requests * fix(HH-547): align production preflight with wildcard CORS --------- Co-authored-by: Rogee <rogee@ipao.vip>
252 lines
9.2 KiB
Go
252 lines
9.2 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Validate checks the configuration for required fields and valid values.
|
|
// Similar to Chatwoot's config validation in config/initializers.
|
|
func Validate(cfg *Config) error {
|
|
// Server validation
|
|
if cfg.Server.Port < 1 || cfg.Server.Port > 65535 {
|
|
return fmt.Errorf("invalid server port: %d", cfg.Server.Port)
|
|
}
|
|
if cfg.Server.Mode != "debug" && cfg.Server.Mode != "release" && cfg.Server.Mode != "test" {
|
|
return fmt.Errorf("invalid server mode: %s (must be debug, release, or test)", cfg.Server.Mode)
|
|
}
|
|
|
|
// Database validation (ref: Chatwoot requires POSTGRES_* env vars)
|
|
if cfg.Database.DSN == "" {
|
|
return fmt.Errorf("database DSN is required")
|
|
}
|
|
dbURL, err := url.Parse(cfg.Database.DSN)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid database DSN: %w", err)
|
|
}
|
|
if dbURL.Scheme != "postgres" && dbURL.Scheme != "postgresql" {
|
|
return fmt.Errorf("invalid database DSN: scheme must be postgres or postgresql, got %s", dbURL.Scheme)
|
|
}
|
|
if dbURL.Host == "" {
|
|
return fmt.Errorf("invalid database DSN: host is required")
|
|
}
|
|
// Redis validation (ref: Chatwoot config/cable.yml requires Redis connection)
|
|
if cfg.Redis.DSN == "" {
|
|
return fmt.Errorf("redis DSN is required")
|
|
}
|
|
redisURL, err := url.Parse(cfg.Redis.DSN)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid redis DSN: %w", err)
|
|
}
|
|
if redisURL.Scheme != "redis" && redisURL.Scheme != "rediss" {
|
|
return fmt.Errorf("invalid redis DSN: scheme must be redis or rediss, got %s", redisURL.Scheme)
|
|
}
|
|
if redisURL.Host == "" {
|
|
return fmt.Errorf("invalid redis DSN: host is required")
|
|
}
|
|
for _, proxy := range cfg.Server.TrustedProxies {
|
|
if net.ParseIP(proxy) == nil {
|
|
if _, _, err := net.ParseCIDR(proxy); err != nil {
|
|
return fmt.Errorf("invalid trusted proxy %q", proxy)
|
|
}
|
|
}
|
|
}
|
|
|
|
// JWT validation
|
|
if cfg.Server.Mode == "release" {
|
|
if cfg.JWT.AllowInsecureHeaderAuth {
|
|
return fmt.Errorf("insecure header authentication is not allowed in release mode")
|
|
}
|
|
if err := validateProductionJWTSecret("JWT secret", cfg.JWT.Secret); err != nil {
|
|
return err
|
|
}
|
|
seen := map[string]bool{cfg.JWT.Secret: true}
|
|
for _, secret := range cfg.JWT.PreviousSecrets {
|
|
if err := validateProductionJWTSecret("previous JWT secret", secret); err != nil {
|
|
return err
|
|
}
|
|
if seen[secret] {
|
|
return fmt.Errorf("previous JWT secrets must be unique and differ from the active secret")
|
|
}
|
|
seen[secret] = true
|
|
}
|
|
}
|
|
// Log validation
|
|
validLogLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true}
|
|
if !validLogLevels[cfg.Log.Level] {
|
|
return fmt.Errorf("invalid log level: %s", cfg.Log.Level)
|
|
}
|
|
|
|
// Worker validation (ref: Chatwoot sidekiq.yml concurrency check)
|
|
if cfg.Worker.Concurrency < 1 {
|
|
return fmt.Errorf("worker concurrency must be >= 1")
|
|
}
|
|
if cfg.Worker.BlockTimeoutS < 1 {
|
|
return fmt.Errorf("worker.redis_block_timeout_s must be >= 1")
|
|
}
|
|
if cfg.Worker.SweepIntervalS < 1 {
|
|
return fmt.Errorf("worker.redis_sweep_interval_s must be >= 1")
|
|
}
|
|
|
|
// Search validation. Meilisearch is the production parity engine; db remains
|
|
// available only as an explicit development fallback.
|
|
engine := strings.ToLower(cfg.Search.Engine)
|
|
if engine == "" {
|
|
engine = "meilisearch"
|
|
}
|
|
if engine != "meilisearch" && engine != "db" {
|
|
return fmt.Errorf("invalid search engine: %s (must be meilisearch or db)", cfg.Search.Engine)
|
|
}
|
|
if engine == "db" && cfg.Server.Mode == "release" {
|
|
return fmt.Errorf("search.engine=db is only allowed for local development; release mode requires meilisearch")
|
|
}
|
|
if engine == "meilisearch" {
|
|
if cfg.Search.Host == "" {
|
|
return fmt.Errorf("search.host is required when search.engine=meilisearch")
|
|
}
|
|
searchURL, err := url.Parse(cfg.Search.Host)
|
|
if err != nil || searchURL.Scheme == "" || searchURL.Host == "" {
|
|
return fmt.Errorf("invalid search.host: %s", cfg.Search.Host)
|
|
}
|
|
}
|
|
if cfg.Search.TimeoutSeconds < 0 {
|
|
return fmt.Errorf("search.timeout_seconds must be >= 0")
|
|
}
|
|
|
|
if cfg.Server.Mode == "release" {
|
|
if dbURL.User == nil || dbURL.User.Username() == "" {
|
|
return fmt.Errorf("production database credentials are required")
|
|
}
|
|
if password, ok := dbURL.User.Password(); !ok || password == "" || containsPlaceholder(password) {
|
|
return fmt.Errorf("production database password is required and must not contain placeholders")
|
|
}
|
|
if err := ValidateProductionDatabaseDSN(cfg.Database.DSN); err != nil {
|
|
return err
|
|
}
|
|
if redisURL.User != nil {
|
|
if password, ok := redisURL.User.Password(); !ok || password == "" || containsPlaceholder(password) {
|
|
return fmt.Errorf("production Redis password is required and must not contain placeholders")
|
|
}
|
|
}
|
|
if redisURL.Scheme == "rediss" && strings.EqualFold(redisURL.Query().Get("insecure_skip_verify"), "true") {
|
|
return fmt.Errorf("production Redis TLS certificate verification cannot be disabled")
|
|
}
|
|
if cfg.JWT.AccessExpiryMinutes <= 0 || cfg.JWT.AccessExpiryMinutes > 15 {
|
|
return fmt.Errorf("production JWT access_expiry_minutes must be between 1 and 15")
|
|
}
|
|
if !cfg.Encryption.Enabled {
|
|
return fmt.Errorf("production sensitive-field encryption must be enabled")
|
|
}
|
|
if len(cfg.Search.APIKey) < 16 || containsPlaceholder(cfg.Search.APIKey) {
|
|
return fmt.Errorf("production search API key must be at least 16 bytes and must not contain placeholders")
|
|
}
|
|
}
|
|
if cfg.Encryption.Enabled {
|
|
key, err := base64.StdEncoding.DecodeString(cfg.Encryption.AESKey)
|
|
if err != nil || len(key) != 32 || cfg.Encryption.CurrentKeyVersion < 1 {
|
|
return fmt.Errorf("encryption requires a base64-encoded 32-byte AES key and positive key version")
|
|
}
|
|
for version, encoded := range cfg.Encryption.PreviousKeys {
|
|
parsedVersion, err := strconv.Atoi(version)
|
|
if err != nil || parsedVersion < 1 || parsedVersion == cfg.Encryption.CurrentKeyVersion {
|
|
return fmt.Errorf("invalid previous encryption key version %q", version)
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
|
if err != nil || len(decoded) != 32 {
|
|
return fmt.Errorf("previous encryption key v%s must be base64-encoded 32 bytes", version)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateProductionDatabaseDSN protects every direct Go database client from
|
|
// local targets, duplicate sslmode downgrades, and unsafe certificate paths.
|
|
func ValidateProductionDatabaseDSN(dsn string) error {
|
|
dbURL, err := url.Parse(dsn)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid production database DSN: %w", err)
|
|
}
|
|
hostname := strings.TrimSuffix(strings.ToLower(dbURL.Hostname()), ".")
|
|
if zone := strings.LastIndexByte(hostname, '%'); zone >= 0 {
|
|
hostname = hostname[:zone]
|
|
}
|
|
ip := net.ParseIP(hostname)
|
|
if hostname == "" || hostname == "postgres" || hostname == "localhost" || ip != nil && ip.IsLoopback() {
|
|
return fmt.Errorf("production database DSN must use an external PostgreSQL host")
|
|
}
|
|
query, err := url.ParseQuery(dbURL.RawQuery)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid production database DSN query: %w", err)
|
|
}
|
|
modes := query["sslmode"]
|
|
if len(modes) != 1 {
|
|
return fmt.Errorf("production database DSN must contain exactly one sslmode")
|
|
}
|
|
if modes[0] != "disable" && modes[0] != "verify-ca" && modes[0] != "verify-full" {
|
|
return fmt.Errorf("production database DSN must use sslmode=disable, verify-full or verify-ca")
|
|
}
|
|
|
|
fixedPaths := map[string]string{
|
|
"sslrootcert": "/run/secrets/external-db-ca.crt",
|
|
"sslcert": "/run/secrets/external-db-client.crt",
|
|
"sslkey": "/run/secrets/external-db-client.key",
|
|
}
|
|
usesCertificateFiles := false
|
|
for parameter := range fixedPaths {
|
|
usesCertificateFiles = usesCertificateFiles || len(query[parameter]) > 0
|
|
}
|
|
if usesCertificateFiles {
|
|
for parameter, path := range fixedPaths {
|
|
values := query[parameter]
|
|
if len(values) != 1 || values[0] != path {
|
|
return fmt.Errorf("production database DSN %s must appear exactly once and use %s", parameter, path)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func containsPlaceholder(value string) bool {
|
|
value = strings.ToLower(value)
|
|
return strings.Contains(value, "change_me") || strings.Contains(value, "change-me") || strings.Contains(value, "changeme")
|
|
}
|
|
|
|
func validateProductionJWTSecret(name, secret string) error {
|
|
secret = strings.TrimSpace(secret)
|
|
if len([]byte(secret)) < 32 {
|
|
return fmt.Errorf("%s must be at least 32 bytes (256 bits) in release mode", name)
|
|
}
|
|
normalized := strings.NewReplacer("-", "_", " ", "_").Replace(strings.ToLower(secret))
|
|
for _, placeholder := range []string{"change_me", "changeme", "change_in_production", "replace_me", "your_jwt_secret", "default_secret", "example_secret", "test_secret", "quickstart"} {
|
|
if strings.Contains(normalized, placeholder) {
|
|
return fmt.Errorf("%s contains a common placeholder", name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ParseStatementTimeout converts the string timeout to time.Duration.
|
|
func ParseStatementTimeout(timeout string) (time.Duration, error) {
|
|
if timeout == "" {
|
|
return 14 * time.Second, nil
|
|
}
|
|
|
|
// Handle plain seconds (e.g., "14s")
|
|
if secs, err := strconv.Atoi(timeout); err == nil {
|
|
return time.Duration(secs) * time.Second, nil
|
|
}
|
|
|
|
// Handle Go duration format (e.g., "14s", "500ms")
|
|
d, err := time.ParseDuration(timeout)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid statement_timeout format: %s", timeout)
|
|
}
|
|
return d, nil
|
|
}
|