514 lines
16 KiB
Go
514 lines
16 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Config holds all application configuration
|
|
type Config struct {
|
|
App AppConfig
|
|
Server ServerConfig
|
|
HTTP HTTPConfig
|
|
Security SecurityConfig
|
|
Database DatabaseConfig
|
|
Cache CacheConfig
|
|
AWS AWSConfig
|
|
Logger LoggerConfig
|
|
Mailgun MailgunConfig
|
|
Meilisearch MeilisearchConfig
|
|
Scheduler SchedulerConfig
|
|
RateLimit RateLimitConfig
|
|
LeaderElection LeaderElectionConfig
|
|
}
|
|
|
|
// AppConfig holds application-level configuration
|
|
type AppConfig struct {
|
|
Environment string
|
|
Version string
|
|
JWTSecret string
|
|
GeoLiteDBPath string
|
|
BannedCountries []string
|
|
}
|
|
|
|
// IsTestMode returns true if the environment is development
|
|
func (c *AppConfig) IsTestMode() bool {
|
|
return c.Environment == "development"
|
|
}
|
|
|
|
// ServerConfig holds HTTP server configuration
|
|
type ServerConfig struct {
|
|
Host string
|
|
Port int
|
|
}
|
|
|
|
// HTTPConfig holds HTTP request handling configuration
|
|
type HTTPConfig struct {
|
|
MaxRequestBodySize int64 // Maximum request body size in bytes
|
|
ReadTimeout time.Duration // Maximum duration for reading the entire request
|
|
WriteTimeout time.Duration // Maximum duration before timing out writes of the response
|
|
IdleTimeout time.Duration // Maximum amount of time to wait for the next request
|
|
}
|
|
|
|
// SecurityConfig holds security-related configuration
|
|
type SecurityConfig struct {
|
|
TrustedProxies []string // CIDR blocks of trusted reverse proxies for X-Forwarded-For validation
|
|
IPEncryptionKey string // 32-character hex key (16 bytes) for IP address encryption (GDPR compliance)
|
|
AllowedOrigins []string // CORS allowed origins (e.g., https://getmaplepress.com)
|
|
}
|
|
|
|
// DatabaseConfig holds Cassandra database configuration
|
|
type DatabaseConfig struct {
|
|
Hosts []string
|
|
Keyspace string
|
|
Consistency string
|
|
Replication int
|
|
MigrationsPath string
|
|
}
|
|
|
|
// CacheConfig holds Redis cache configuration
|
|
type CacheConfig struct {
|
|
Host string
|
|
Port int
|
|
Password string
|
|
DB int
|
|
}
|
|
|
|
// AWSConfig holds AWS S3 configuration
|
|
type AWSConfig struct {
|
|
AccessKey string
|
|
SecretKey string
|
|
Endpoint string
|
|
Region string
|
|
BucketName string
|
|
}
|
|
|
|
// LoggerConfig holds logging configuration
|
|
type LoggerConfig struct {
|
|
Level string
|
|
Format string
|
|
}
|
|
|
|
// MailgunConfig holds Mailgun email service configuration
|
|
type MailgunConfig struct {
|
|
APIKey string
|
|
Domain string
|
|
APIBase string
|
|
SenderEmail string
|
|
MaintenanceEmail string
|
|
FrontendDomain string
|
|
BackendDomain string
|
|
}
|
|
|
|
// MeilisearchConfig holds Meilisearch configuration
|
|
type MeilisearchConfig struct {
|
|
Host string
|
|
APIKey string
|
|
IndexPrefix string
|
|
}
|
|
|
|
// SchedulerConfig holds scheduler configuration
|
|
type SchedulerConfig struct {
|
|
QuotaResetEnabled bool
|
|
QuotaResetSchedule string // Cron format: "0 0 1 * *" = first day of month at midnight
|
|
IPCleanupEnabled bool
|
|
IPCleanupSchedule string // Cron format: "0 2 * * *" = daily at 2 AM
|
|
}
|
|
|
|
// RateLimitConfig holds rate limiting configuration
|
|
type RateLimitConfig struct {
|
|
// Registration endpoint rate limiting
|
|
// CWE-307: Prevents automated account creation and bot signups
|
|
RegistrationEnabled bool
|
|
RegistrationMaxRequests int
|
|
RegistrationWindow time.Duration
|
|
|
|
// Login endpoint rate limiting
|
|
// CWE-307: Dual protection (IP-based + account lockout) against brute force attacks
|
|
LoginEnabled bool
|
|
LoginMaxAttemptsPerIP int
|
|
LoginIPWindow time.Duration
|
|
LoginMaxFailedAttemptsPerAccount int
|
|
LoginAccountLockoutDuration time.Duration
|
|
|
|
// Generic CRUD endpoints rate limiting
|
|
// CWE-770: Protects authenticated endpoints (tenant/user/site management) from resource exhaustion
|
|
GenericEnabled bool
|
|
GenericMaxRequests int
|
|
GenericWindow time.Duration
|
|
|
|
// Plugin API endpoints rate limiting
|
|
// CWE-770: Lenient limits for core business endpoints (WordPress plugin integration)
|
|
PluginAPIEnabled bool
|
|
PluginAPIMaxRequests int
|
|
PluginAPIWindow time.Duration
|
|
}
|
|
|
|
// LeaderElectionConfig holds leader election configuration
|
|
type LeaderElectionConfig struct {
|
|
Enabled bool
|
|
LockTTL time.Duration
|
|
HeartbeatInterval time.Duration
|
|
RetryInterval time.Duration
|
|
}
|
|
|
|
// Load loads configuration from environment variables
|
|
func Load() (*Config, error) {
|
|
cfg := &Config{
|
|
App: AppConfig{
|
|
Environment: getEnv("APP_ENVIRONMENT", "development"),
|
|
Version: getEnv("APP_VERSION", "0.1.0"),
|
|
JWTSecret: getEnv("APP_JWT_SECRET", "change-me-in-production"),
|
|
GeoLiteDBPath: getEnv("APP_GEOLITE_DB_PATH", ""),
|
|
BannedCountries: getEnvAsSlice("APP_BANNED_COUNTRIES", []string{}),
|
|
},
|
|
Server: ServerConfig{
|
|
Host: getEnv("SERVER_HOST", "0.0.0.0"),
|
|
Port: getEnvAsInt("SERVER_PORT", 8000),
|
|
},
|
|
HTTP: HTTPConfig{
|
|
MaxRequestBodySize: getEnvAsInt64("HTTP_MAX_REQUEST_BODY_SIZE", 10*1024*1024), // 10 MB default
|
|
ReadTimeout: getEnvAsDuration("HTTP_READ_TIMEOUT", 30*time.Second),
|
|
WriteTimeout: getEnvAsDuration("HTTP_WRITE_TIMEOUT", 30*time.Second),
|
|
IdleTimeout: getEnvAsDuration("HTTP_IDLE_TIMEOUT", 60*time.Second),
|
|
},
|
|
Security: SecurityConfig{
|
|
// CWE-348: Trusted proxies for X-Forwarded-For validation
|
|
// Example: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" for private networks
|
|
// Leave empty to disable X-Forwarded-For trust (most secure for direct connections)
|
|
TrustedProxies: getEnvAsSlice("SECURITY_TRUSTED_PROXIES", []string{}),
|
|
// CWE-359: IP encryption key for GDPR compliance
|
|
// Must be 32 hex characters (16 bytes). Generate with: openssl rand -hex 16
|
|
IPEncryptionKey: getEnv("SECURITY_IP_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff"),
|
|
// CORS allowed origins (comma-separated)
|
|
// Example: "https://getmaplepress.com,https://www.getmaplepress.com"
|
|
// In development, localhost origins are automatically added
|
|
AllowedOrigins: getEnvAsSlice("SECURITY_CORS_ALLOWED_ORIGINS", []string{}),
|
|
},
|
|
Database: DatabaseConfig{
|
|
Hosts: getEnvAsSlice("DATABASE_HOSTS", []string{"localhost"}),
|
|
Keyspace: getEnv("DATABASE_KEYSPACE", "maplepress"),
|
|
Consistency: getEnv("DATABASE_CONSISTENCY", "QUORUM"),
|
|
Replication: getEnvAsInt("DATABASE_REPLICATION", 3),
|
|
MigrationsPath: getEnv("DATABASE_MIGRATIONS_PATH", "file://migrations"),
|
|
},
|
|
Cache: CacheConfig{
|
|
Host: getEnv("CACHE_HOST", "localhost"),
|
|
Port: getEnvAsInt("CACHE_PORT", 6379),
|
|
Password: getEnv("CACHE_PASSWORD", ""),
|
|
DB: getEnvAsInt("CACHE_DB", 0),
|
|
},
|
|
AWS: AWSConfig{
|
|
AccessKey: getEnv("AWS_ACCESS_KEY", ""),
|
|
SecretKey: getEnv("AWS_SECRET_KEY", ""),
|
|
Endpoint: getEnv("AWS_ENDPOINT", ""),
|
|
Region: getEnv("AWS_REGION", "us-east-1"),
|
|
BucketName: getEnv("AWS_BUCKET_NAME", ""),
|
|
},
|
|
Logger: LoggerConfig{
|
|
Level: getEnv("LOGGER_LEVEL", "info"),
|
|
Format: getEnv("LOGGER_FORMAT", "json"),
|
|
},
|
|
Mailgun: MailgunConfig{
|
|
APIKey: getEnv("MAILGUN_API_KEY", ""),
|
|
Domain: getEnv("MAILGUN_DOMAIN", ""),
|
|
APIBase: getEnv("MAILGUN_API_BASE", "https://api.mailgun.net/v3"),
|
|
SenderEmail: getEnv("MAILGUN_SENDER_EMAIL", "noreply@maplepress.app"),
|
|
MaintenanceEmail: getEnv("MAILGUN_MAINTENANCE_EMAIL", "admin@maplepress.app"),
|
|
FrontendDomain: getEnv("MAILGUN_FRONTEND_DOMAIN", "https://maplepress.app"),
|
|
BackendDomain: getEnv("MAILGUN_BACKEND_DOMAIN", "https://api.maplepress.app"),
|
|
},
|
|
Meilisearch: MeilisearchConfig{
|
|
Host: getEnv("MEILISEARCH_HOST", "http://localhost:7700"),
|
|
APIKey: getEnv("MEILISEARCH_API_KEY", ""),
|
|
IndexPrefix: getEnv("MEILISEARCH_INDEX_PREFIX", "site_"),
|
|
},
|
|
Scheduler: SchedulerConfig{
|
|
QuotaResetEnabled: getEnvAsBool("SCHEDULER_QUOTA_RESET_ENABLED", true),
|
|
QuotaResetSchedule: getEnv("SCHEDULER_QUOTA_RESET_SCHEDULE", "0 0 1 * *"), // 1st of month at midnight
|
|
IPCleanupEnabled: getEnvAsBool("SCHEDULER_IP_CLEANUP_ENABLED", true), // CWE-359: GDPR compliance
|
|
IPCleanupSchedule: getEnv("SCHEDULER_IP_CLEANUP_SCHEDULE", "0 2 * * *"), // Daily at 2 AM
|
|
},
|
|
RateLimit: RateLimitConfig{
|
|
// Registration rate limiting (CWE-307)
|
|
RegistrationEnabled: getEnvAsBool("RATELIMIT_REGISTRATION_ENABLED", true),
|
|
RegistrationMaxRequests: getEnvAsInt("RATELIMIT_REGISTRATION_MAX_REQUESTS", 5),
|
|
RegistrationWindow: getEnvAsDuration("RATELIMIT_REGISTRATION_WINDOW", time.Hour),
|
|
|
|
// Login rate limiting (CWE-307)
|
|
LoginEnabled: getEnvAsBool("RATELIMIT_LOGIN_ENABLED", true),
|
|
LoginMaxAttemptsPerIP: getEnvAsInt("RATELIMIT_LOGIN_MAX_ATTEMPTS_PER_IP", 10),
|
|
LoginIPWindow: getEnvAsDuration("RATELIMIT_LOGIN_IP_WINDOW", 15*time.Minute),
|
|
LoginMaxFailedAttemptsPerAccount: getEnvAsInt("RATELIMIT_LOGIN_MAX_FAILED_ATTEMPTS_PER_ACCOUNT", 10),
|
|
LoginAccountLockoutDuration: getEnvAsDuration("RATELIMIT_LOGIN_ACCOUNT_LOCKOUT_DURATION", 30*time.Minute),
|
|
|
|
// Generic CRUD endpoints rate limiting (CWE-770)
|
|
GenericEnabled: getEnvAsBool("RATELIMIT_GENERIC_ENABLED", true),
|
|
GenericMaxRequests: getEnvAsInt("RATELIMIT_GENERIC_MAX_REQUESTS", 100),
|
|
GenericWindow: getEnvAsDuration("RATELIMIT_GENERIC_WINDOW", time.Hour),
|
|
|
|
// Plugin API endpoints rate limiting (CWE-770) - Anti-abuse only
|
|
// Generous limits for usage-based billing (no hard quotas)
|
|
PluginAPIEnabled: getEnvAsBool("RATELIMIT_PLUGIN_API_ENABLED", true),
|
|
PluginAPIMaxRequests: getEnvAsInt("RATELIMIT_PLUGIN_API_MAX_REQUESTS", 10000),
|
|
PluginAPIWindow: getEnvAsDuration("RATELIMIT_PLUGIN_API_WINDOW", time.Hour),
|
|
},
|
|
LeaderElection: LeaderElectionConfig{
|
|
Enabled: getEnvAsBool("LEADER_ELECTION_ENABLED", true),
|
|
LockTTL: getEnvAsDuration("LEADER_ELECTION_LOCK_TTL", 10*time.Second),
|
|
HeartbeatInterval: getEnvAsDuration("LEADER_ELECTION_HEARTBEAT_INTERVAL", 3*time.Second),
|
|
RetryInterval: getEnvAsDuration("LEADER_ELECTION_RETRY_INTERVAL", 2*time.Second),
|
|
},
|
|
}
|
|
|
|
// Validate configuration
|
|
if err := cfg.validate(); err != nil {
|
|
return nil, fmt.Errorf("invalid configuration: %w", err)
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// GetSchedulerConfig returns scheduler configuration values
|
|
func (c *Config) GetSchedulerConfig() (enabled bool, schedule string) {
|
|
return c.Scheduler.QuotaResetEnabled, c.Scheduler.QuotaResetSchedule
|
|
}
|
|
|
|
// validate checks if the configuration is valid
|
|
func (c *Config) validate() error {
|
|
if c.Server.Port < 1 || c.Server.Port > 65535 {
|
|
return fmt.Errorf("invalid server port: %d", c.Server.Port)
|
|
}
|
|
|
|
if c.Database.Keyspace == "" {
|
|
return fmt.Errorf("database keyspace is required")
|
|
}
|
|
|
|
if len(c.Database.Hosts) == 0 {
|
|
return fmt.Errorf("at least one database host is required")
|
|
}
|
|
|
|
if c.App.JWTSecret == "" {
|
|
return fmt.Errorf("APP_JWT_SECRET is required")
|
|
}
|
|
|
|
// Security validation for credentials (CWE-798: Use of Hard-coded Credentials)
|
|
if err := c.validateSecurityCredentials(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateSecurityCredentials performs security validation on credentials
|
|
// This addresses CWE-798 (Use of Hard-coded Credentials)
|
|
func (c *Config) validateSecurityCredentials() error {
|
|
// Check if JWT secret is using the default hard-coded value
|
|
if strings.Contains(strings.ToLower(c.App.JWTSecret), "change-me") ||
|
|
strings.Contains(strings.ToLower(c.App.JWTSecret), "changeme") {
|
|
|
|
if c.App.Environment == "production" {
|
|
return fmt.Errorf(
|
|
"SECURITY ERROR: JWT secret is using default/placeholder value in production. " +
|
|
"Generate a secure secret with: openssl rand -base64 64",
|
|
)
|
|
}
|
|
|
|
// Warn in development
|
|
log.Printf(
|
|
"[WARNING] JWT secret is using default/placeholder value. " +
|
|
"This is acceptable for development but MUST be changed for production. " +
|
|
"Generate a secure secret with: openssl rand -base64 64",
|
|
)
|
|
}
|
|
|
|
// Validate IP encryption key format (CWE-359: GDPR compliance)
|
|
if c.Security.IPEncryptionKey != "" {
|
|
if len(c.Security.IPEncryptionKey) != 32 {
|
|
return fmt.Errorf(
|
|
"SECURITY ERROR: IP encryption key must be exactly 32 hex characters (16 bytes). " +
|
|
"Generate with: openssl rand -hex 16",
|
|
)
|
|
}
|
|
// Check if valid hex
|
|
for _, char := range c.Security.IPEncryptionKey {
|
|
if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F')) {
|
|
return fmt.Errorf(
|
|
"SECURITY ERROR: IP encryption key must contain only hex characters (0-9, a-f). " +
|
|
"Generate with: openssl rand -hex 16",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// In production, enforce additional security checks
|
|
if c.App.Environment == "production" {
|
|
// Check IP encryption key is not using default value
|
|
if c.Security.IPEncryptionKey == "00112233445566778899aabbccddeeff" {
|
|
return fmt.Errorf(
|
|
"SECURITY ERROR: IP encryption key is using default value in production. " +
|
|
"Generate a secure key with: openssl rand -hex 16",
|
|
)
|
|
}
|
|
|
|
// Check JWT secret minimum length
|
|
if len(c.App.JWTSecret) < 32 {
|
|
return fmt.Errorf(
|
|
"SECURITY ERROR: JWT secret is too short for production (%d characters). "+
|
|
"Minimum required: 32 characters (256 bits). "+
|
|
"Generate a secure secret with: openssl rand -base64 64",
|
|
len(c.App.JWTSecret),
|
|
)
|
|
}
|
|
|
|
// Check for common weak secrets
|
|
weakSecrets := []string{"secret", "password", "12345", "admin", "test", "default"}
|
|
secretLower := strings.ToLower(c.App.JWTSecret)
|
|
for _, weak := range weakSecrets {
|
|
if secretLower == weak {
|
|
return fmt.Errorf(
|
|
"SECURITY ERROR: JWT secret is using a common weak value: '%s'. "+
|
|
"Generate a secure secret with: openssl rand -base64 64",
|
|
weak,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Check Meilisearch API key in production
|
|
if c.Meilisearch.APIKey == "" {
|
|
return fmt.Errorf("SECURITY ERROR: Meilisearch API key must be set in production")
|
|
}
|
|
|
|
meilisearchKeyLower := strings.ToLower(c.Meilisearch.APIKey)
|
|
if strings.Contains(meilisearchKeyLower, "change") ||
|
|
strings.Contains(meilisearchKeyLower, "dev") ||
|
|
strings.Contains(meilisearchKeyLower, "test") {
|
|
return fmt.Errorf(
|
|
"SECURITY ERROR: Meilisearch API key appears to be a development/placeholder value",
|
|
)
|
|
}
|
|
|
|
// Check database hosts are not using localhost in production
|
|
for _, host := range c.Database.Hosts {
|
|
hostLower := strings.ToLower(host)
|
|
if strings.Contains(hostLower, "localhost") || host == "127.0.0.1" {
|
|
return fmt.Errorf(
|
|
"SECURITY ERROR: Database hosts should not use localhost in production. Found: %s",
|
|
host,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Check cache host is not localhost in production
|
|
cacheLower := strings.ToLower(c.Cache.Host)
|
|
if strings.Contains(cacheLower, "localhost") || c.Cache.Host == "127.0.0.1" {
|
|
return fmt.Errorf(
|
|
"SECURITY ERROR: Cache host should not use localhost in production. Found: %s",
|
|
c.Cache.Host,
|
|
)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Helper functions to get environment variables
|
|
|
|
func getEnv(key, defaultValue string) string {
|
|
value := os.Getenv(key)
|
|
if value == "" {
|
|
return defaultValue
|
|
}
|
|
return value
|
|
}
|
|
|
|
func getEnvAsInt(key string, defaultValue int) int {
|
|
valueStr := os.Getenv(key)
|
|
if valueStr == "" {
|
|
return defaultValue
|
|
}
|
|
|
|
value, err := strconv.Atoi(valueStr)
|
|
if err != nil {
|
|
return defaultValue
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
func getEnvAsInt64(key string, defaultValue int64) int64 {
|
|
valueStr := os.Getenv(key)
|
|
if valueStr == "" {
|
|
return defaultValue
|
|
}
|
|
|
|
value, err := strconv.ParseInt(valueStr, 10, 64)
|
|
if err != nil {
|
|
return defaultValue
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
func getEnvAsBool(key string, defaultValue bool) bool {
|
|
valueStr := os.Getenv(key)
|
|
if valueStr == "" {
|
|
return defaultValue
|
|
}
|
|
|
|
value, err := strconv.ParseBool(valueStr)
|
|
if err != nil {
|
|
return defaultValue
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
func getEnvAsSlice(key string, defaultValue []string) []string {
|
|
valueStr := os.Getenv(key)
|
|
if valueStr == "" {
|
|
return defaultValue
|
|
}
|
|
|
|
// Simple comma-separated parsing
|
|
// For production, consider using a proper CSV parser
|
|
var result []string
|
|
current := ""
|
|
for _, char := range valueStr {
|
|
if char == ',' {
|
|
if current != "" {
|
|
result = append(result, current)
|
|
current = ""
|
|
}
|
|
} else {
|
|
current += string(char)
|
|
}
|
|
}
|
|
if current != "" {
|
|
result = append(result, current)
|
|
}
|
|
|
|
if len(result) == 0 {
|
|
return defaultValue
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func getEnvAsDuration(key string, defaultValue time.Duration) time.Duration {
|
|
valueStr := os.Getenv(key)
|
|
if valueStr == "" {
|
|
return defaultValue
|
|
}
|
|
|
|
value, err := time.ParseDuration(valueStr)
|
|
if err != nil {
|
|
return defaultValue
|
|
}
|
|
|
|
return value
|
|
}
|