Initial commit: Open sourcing all of the Maple Open Technologies code.

This commit is contained in:
Bartlomiej Mika 2025-12-02 14:33:08 -05:00
commit 755d54a99d
2010 changed files with 448675 additions and 0 deletions

View file

@ -0,0 +1,514 @@
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
}