Fix 113 hardening issues across entire Go backend
Security: - Replace all binding: tags with validate: + c.Validate() in admin handlers - Add rate limiting to auth endpoints (login, register, password reset) - Add security headers (HSTS, XSS protection, nosniff, frame options) - Wire Google Pub/Sub token verification into webhook handler - Replace ParseUnverified with proper OIDC/JWKS key verification - Verify inner Apple JWS signatures in webhook handler - Add io.LimitReader (1MB) to all webhook body reads - Add ownership verification to file deletion - Move hardcoded admin credentials to env vars - Add uniqueIndex to User.Email - Hide ConfirmationCode from JSON serialization - Mask confirmation codes in admin responses - Use http.DetectContentType for upload validation - Fix path traversal in storage service - Replace os.Getenv with Viper in stripe service - Sanitize Redis URLs before logging - Separate DEBUG_FIXED_CODES from DEBUG flag - Reject weak SECRET_KEY in production - Add host check on /_next/* proxy routes - Use explicit localhost CORS origins in debug mode - Replace err.Error() with generic messages in all admin error responses Critical fixes: - Rewrite FCM to HTTP v1 API with OAuth 2.0 service account auth - Fix user_customuser -> auth_user table names in raw SQL - Fix dashboard verified query to use UserProfile model - Add escapeLikeWildcards() to prevent SQL wildcard injection Bug fixes: - Add bounds checks for days/expiring_soon query params (1-3650) - Add receipt_data/transaction_id empty-check to RestoreSubscription - Change Active bool -> *bool in device handler - Check all unchecked GORM/FindByIDWithProfile errors - Add validation for notification hour fields (0-23) - Add max=10000 validation on task description updates Transactions & data integrity: - Wrap registration flow in transaction - Wrap QuickComplete in transaction - Move image creation inside completion transaction - Wrap SetSpecialties in transaction - Wrap GetOrCreateToken in transaction - Wrap completion+image deletion in transaction Performance: - Batch completion summaries (2 queries vs 2N) - Reuse single http.Client in IAP validation - Cache dashboard counts (30s TTL) - Batch COUNT queries in admin user list - Add Limit(500) to document queries - Add reminder_stage+due_date filters to reminder queries - Parse AllowedTypes once at init - In-memory user cache in auth middleware (30s TTL) - Timezone change detection cache - Optimize P95 with per-endpoint sorted buffers - Replace crypto/md5 with hash/fnv for ETags Code quality: - Add sync.Once to all monitoring Stop()/Close() methods - Replace 8 fmt.Printf with zerolog in auth service - Log previously discarded errors - Standardize delete response shapes - Route hardcoded English through i18n - Remove FileURL from DocumentResponse (keep MediaURL only) - Thread user timezone through kanban board responses - Initialize empty slices to prevent null JSON - Extract shared field map for task Update/UpdateTx - Delete unused SoftDeleteModel, min(), formatCron, legacy handlers Worker & jobs: - Wire Asynq email infrastructure into worker - Register HandleReminderLogCleanup with daily 3AM cron - Use per-user timezone in HandleSmartReminder - Replace direct DB queries with repository calls - Delete legacy reminder handlers (~200 lines) - Delete unused task type constants Dependencies: - Replace archived jung-kurt/gofpdf with go-pdf/fpdf - Replace unmaintained gomail.v2 with wneessen/go-mail - Add TODO for Echo jwt v3 transitive dep removal Test infrastructure: - Fix MakeRequest/SeedLookupData error handling - Replace os.Exit(0) with t.Skip() in scope/consistency tests - Add 11 new FCM v1 tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
@@ -32,6 +33,7 @@ type Config struct {
|
||||
type ServerConfig struct {
|
||||
Port int
|
||||
Debug bool
|
||||
DebugFixedCodes bool // Separate from Debug: enables fixed confirmation codes for local testing
|
||||
AllowedHosts []string
|
||||
CorsAllowedOrigins []string // Comma-separated origins for CORS (production only; debug uses wildcard)
|
||||
Timezone string
|
||||
@@ -75,7 +77,12 @@ type PushConfig struct {
|
||||
APNSSandbox bool
|
||||
APNSProduction bool // If true, use production APNs; if false, use sandbox
|
||||
|
||||
// FCM (Android) - uses direct HTTP to FCM legacy API
|
||||
// FCM (Android) - uses FCM HTTP v1 API with OAuth 2.0
|
||||
FCMProjectID string // Firebase project ID (required for v1 API)
|
||||
FCMServiceAccountPath string // Path to Google service account JSON file
|
||||
FCMServiceAccountJSON string // Raw service account JSON (alternative to path, e.g. for env var injection)
|
||||
|
||||
// Deprecated: FCMServerKey is for the legacy HTTP API. Use FCMProjectID + service account instead.
|
||||
FCMServerKey string
|
||||
}
|
||||
|
||||
@@ -147,135 +154,166 @@ type FeatureFlags struct {
|
||||
WorkerEnabled bool // FEATURE_WORKER_ENABLED (default: true)
|
||||
}
|
||||
|
||||
var cfg *Config
|
||||
var (
|
||||
cfg *Config
|
||||
cfgOnce sync.Once
|
||||
)
|
||||
|
||||
// knownWeakSecretKeys contains well-known default or placeholder secret keys
|
||||
// that must not be used in production.
|
||||
var knownWeakSecretKeys = map[string]bool{
|
||||
"secret": true,
|
||||
"changeme": true,
|
||||
"change-me": true,
|
||||
"password": true,
|
||||
"change-me-in-production-secret-key-12345": true,
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables
|
||||
func Load() (*Config, error) {
|
||||
viper.SetEnvPrefix("")
|
||||
viper.AutomaticEnv()
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
var loadErr error
|
||||
|
||||
// Set defaults
|
||||
setDefaults()
|
||||
cfgOnce.Do(func() {
|
||||
viper.SetEnvPrefix("")
|
||||
viper.AutomaticEnv()
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
|
||||
// Parse DATABASE_URL if set (Dokku-style)
|
||||
dbConfig := DatabaseConfig{
|
||||
Host: viper.GetString("DB_HOST"),
|
||||
Port: viper.GetInt("DB_PORT"),
|
||||
User: viper.GetString("POSTGRES_USER"),
|
||||
Password: viper.GetString("POSTGRES_PASSWORD"),
|
||||
Database: viper.GetString("POSTGRES_DB"),
|
||||
SSLMode: viper.GetString("DB_SSLMODE"),
|
||||
MaxOpenConns: viper.GetInt("DB_MAX_OPEN_CONNS"),
|
||||
MaxIdleConns: viper.GetInt("DB_MAX_IDLE_CONNS"),
|
||||
MaxLifetime: viper.GetDuration("DB_MAX_LIFETIME"),
|
||||
}
|
||||
// Set defaults
|
||||
setDefaults()
|
||||
|
||||
// Override with DATABASE_URL if present
|
||||
if databaseURL := viper.GetString("DATABASE_URL"); databaseURL != "" {
|
||||
parsed, err := parseDatabaseURL(databaseURL)
|
||||
if err == nil {
|
||||
dbConfig.Host = parsed.Host
|
||||
dbConfig.Port = parsed.Port
|
||||
dbConfig.User = parsed.User
|
||||
dbConfig.Password = parsed.Password
|
||||
dbConfig.Database = parsed.Database
|
||||
if parsed.SSLMode != "" {
|
||||
dbConfig.SSLMode = parsed.SSLMode
|
||||
// Parse DATABASE_URL if set (Dokku-style)
|
||||
dbConfig := DatabaseConfig{
|
||||
Host: viper.GetString("DB_HOST"),
|
||||
Port: viper.GetInt("DB_PORT"),
|
||||
User: viper.GetString("POSTGRES_USER"),
|
||||
Password: viper.GetString("POSTGRES_PASSWORD"),
|
||||
Database: viper.GetString("POSTGRES_DB"),
|
||||
SSLMode: viper.GetString("DB_SSLMODE"),
|
||||
MaxOpenConns: viper.GetInt("DB_MAX_OPEN_CONNS"),
|
||||
MaxIdleConns: viper.GetInt("DB_MAX_IDLE_CONNS"),
|
||||
MaxLifetime: viper.GetDuration("DB_MAX_LIFETIME"),
|
||||
}
|
||||
|
||||
// Override with DATABASE_URL if present (F-16: log warning on parse failure)
|
||||
if databaseURL := viper.GetString("DATABASE_URL"); databaseURL != "" {
|
||||
parsed, err := parseDatabaseURL(databaseURL)
|
||||
if err != nil {
|
||||
maskedURL := MaskURLCredentials(databaseURL)
|
||||
fmt.Printf("WARNING: Failed to parse DATABASE_URL (%s): %v — falling back to individual DB_* env vars\n", maskedURL, err)
|
||||
} else {
|
||||
dbConfig.Host = parsed.Host
|
||||
dbConfig.Port = parsed.Port
|
||||
dbConfig.User = parsed.User
|
||||
dbConfig.Password = parsed.Password
|
||||
dbConfig.Database = parsed.Database
|
||||
if parsed.SSLMode != "" {
|
||||
dbConfig.SSLMode = parsed.SSLMode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg = &Config{
|
||||
Server: ServerConfig{
|
||||
Port: viper.GetInt("PORT"),
|
||||
Debug: viper.GetBool("DEBUG"),
|
||||
AllowedHosts: strings.Split(viper.GetString("ALLOWED_HOSTS"), ","),
|
||||
CorsAllowedOrigins: parseCorsOrigins(viper.GetString("CORS_ALLOWED_ORIGINS")),
|
||||
Timezone: viper.GetString("TIMEZONE"),
|
||||
StaticDir: viper.GetString("STATIC_DIR"),
|
||||
BaseURL: viper.GetString("BASE_URL"),
|
||||
},
|
||||
Database: dbConfig,
|
||||
Redis: RedisConfig{
|
||||
URL: viper.GetString("REDIS_URL"),
|
||||
Password: viper.GetString("REDIS_PASSWORD"),
|
||||
DB: viper.GetInt("REDIS_DB"),
|
||||
},
|
||||
Email: EmailConfig{
|
||||
Host: viper.GetString("EMAIL_HOST"),
|
||||
Port: viper.GetInt("EMAIL_PORT"),
|
||||
User: viper.GetString("EMAIL_HOST_USER"),
|
||||
Password: viper.GetString("EMAIL_HOST_PASSWORD"),
|
||||
From: viper.GetString("DEFAULT_FROM_EMAIL"),
|
||||
UseTLS: viper.GetBool("EMAIL_USE_TLS"),
|
||||
},
|
||||
Push: PushConfig{
|
||||
APNSKeyPath: viper.GetString("APNS_AUTH_KEY_PATH"),
|
||||
APNSKeyID: viper.GetString("APNS_AUTH_KEY_ID"),
|
||||
APNSTeamID: viper.GetString("APNS_TEAM_ID"),
|
||||
APNSTopic: viper.GetString("APNS_TOPIC"),
|
||||
APNSSandbox: viper.GetBool("APNS_USE_SANDBOX"),
|
||||
APNSProduction: viper.GetBool("APNS_PRODUCTION"),
|
||||
FCMServerKey: viper.GetString("FCM_SERVER_KEY"),
|
||||
},
|
||||
Worker: WorkerConfig{
|
||||
TaskReminderHour: viper.GetInt("TASK_REMINDER_HOUR"),
|
||||
OverdueReminderHour: viper.GetInt("OVERDUE_REMINDER_HOUR"),
|
||||
DailyNotifHour: viper.GetInt("DAILY_DIGEST_HOUR"),
|
||||
},
|
||||
Security: SecurityConfig{
|
||||
SecretKey: viper.GetString("SECRET_KEY"),
|
||||
TokenCacheTTL: 5 * time.Minute,
|
||||
PasswordResetExpiry: 15 * time.Minute,
|
||||
ConfirmationExpiry: 24 * time.Hour,
|
||||
MaxPasswordResetRate: 3,
|
||||
},
|
||||
Storage: StorageConfig{
|
||||
UploadDir: viper.GetString("STORAGE_UPLOAD_DIR"),
|
||||
BaseURL: viper.GetString("STORAGE_BASE_URL"),
|
||||
MaxFileSize: viper.GetInt64("STORAGE_MAX_FILE_SIZE"),
|
||||
AllowedTypes: viper.GetString("STORAGE_ALLOWED_TYPES"),
|
||||
},
|
||||
AppleAuth: AppleAuthConfig{
|
||||
ClientID: viper.GetString("APPLE_CLIENT_ID"),
|
||||
TeamID: viper.GetString("APPLE_TEAM_ID"),
|
||||
},
|
||||
GoogleAuth: GoogleAuthConfig{
|
||||
ClientID: viper.GetString("GOOGLE_CLIENT_ID"),
|
||||
AndroidClientID: viper.GetString("GOOGLE_ANDROID_CLIENT_ID"),
|
||||
IOSClientID: viper.GetString("GOOGLE_IOS_CLIENT_ID"),
|
||||
},
|
||||
AppleIAP: AppleIAPConfig{
|
||||
KeyPath: viper.GetString("APPLE_IAP_KEY_PATH"),
|
||||
KeyID: viper.GetString("APPLE_IAP_KEY_ID"),
|
||||
IssuerID: viper.GetString("APPLE_IAP_ISSUER_ID"),
|
||||
BundleID: viper.GetString("APPLE_IAP_BUNDLE_ID"),
|
||||
Sandbox: viper.GetBool("APPLE_IAP_SANDBOX"),
|
||||
},
|
||||
GoogleIAP: GoogleIAPConfig{
|
||||
ServiceAccountPath: viper.GetString("GOOGLE_IAP_SERVICE_ACCOUNT_PATH"),
|
||||
PackageName: viper.GetString("GOOGLE_IAP_PACKAGE_NAME"),
|
||||
},
|
||||
Stripe: StripeConfig{
|
||||
SecretKey: viper.GetString("STRIPE_SECRET_KEY"),
|
||||
WebhookSecret: viper.GetString("STRIPE_WEBHOOK_SECRET"),
|
||||
PriceMonthly: viper.GetString("STRIPE_PRICE_MONTHLY"),
|
||||
PriceYearly: viper.GetString("STRIPE_PRICE_YEARLY"),
|
||||
},
|
||||
Features: FeatureFlags{
|
||||
PushEnabled: viper.GetBool("FEATURE_PUSH_ENABLED"),
|
||||
EmailEnabled: viper.GetBool("FEATURE_EMAIL_ENABLED"),
|
||||
WebhooksEnabled: viper.GetBool("FEATURE_WEBHOOKS_ENABLED"),
|
||||
OnboardingEmailsEnabled: viper.GetBool("FEATURE_ONBOARDING_EMAILS_ENABLED"),
|
||||
PDFReportsEnabled: viper.GetBool("FEATURE_PDF_REPORTS_ENABLED"),
|
||||
WorkerEnabled: viper.GetBool("FEATURE_WORKER_ENABLED"),
|
||||
},
|
||||
}
|
||||
cfg = &Config{
|
||||
Server: ServerConfig{
|
||||
Port: viper.GetInt("PORT"),
|
||||
Debug: viper.GetBool("DEBUG"),
|
||||
DebugFixedCodes: viper.GetBool("DEBUG_FIXED_CODES"),
|
||||
AllowedHosts: strings.Split(viper.GetString("ALLOWED_HOSTS"), ","),
|
||||
CorsAllowedOrigins: parseCorsOrigins(viper.GetString("CORS_ALLOWED_ORIGINS")),
|
||||
Timezone: viper.GetString("TIMEZONE"),
|
||||
StaticDir: viper.GetString("STATIC_DIR"),
|
||||
BaseURL: viper.GetString("BASE_URL"),
|
||||
},
|
||||
Database: dbConfig,
|
||||
Redis: RedisConfig{
|
||||
URL: viper.GetString("REDIS_URL"),
|
||||
Password: viper.GetString("REDIS_PASSWORD"),
|
||||
DB: viper.GetInt("REDIS_DB"),
|
||||
},
|
||||
Email: EmailConfig{
|
||||
Host: viper.GetString("EMAIL_HOST"),
|
||||
Port: viper.GetInt("EMAIL_PORT"),
|
||||
User: viper.GetString("EMAIL_HOST_USER"),
|
||||
Password: viper.GetString("EMAIL_HOST_PASSWORD"),
|
||||
From: viper.GetString("DEFAULT_FROM_EMAIL"),
|
||||
UseTLS: viper.GetBool("EMAIL_USE_TLS"),
|
||||
},
|
||||
Push: PushConfig{
|
||||
APNSKeyPath: viper.GetString("APNS_AUTH_KEY_PATH"),
|
||||
APNSKeyID: viper.GetString("APNS_AUTH_KEY_ID"),
|
||||
APNSTeamID: viper.GetString("APNS_TEAM_ID"),
|
||||
APNSTopic: viper.GetString("APNS_TOPIC"),
|
||||
APNSSandbox: viper.GetBool("APNS_USE_SANDBOX"),
|
||||
APNSProduction: viper.GetBool("APNS_PRODUCTION"),
|
||||
FCMProjectID: viper.GetString("FCM_PROJECT_ID"),
|
||||
FCMServiceAccountPath: viper.GetString("FCM_SERVICE_ACCOUNT_PATH"),
|
||||
FCMServiceAccountJSON: viper.GetString("FCM_SERVICE_ACCOUNT_JSON"),
|
||||
FCMServerKey: viper.GetString("FCM_SERVER_KEY"),
|
||||
},
|
||||
Worker: WorkerConfig{
|
||||
TaskReminderHour: viper.GetInt("TASK_REMINDER_HOUR"),
|
||||
OverdueReminderHour: viper.GetInt("OVERDUE_REMINDER_HOUR"),
|
||||
DailyNotifHour: viper.GetInt("DAILY_DIGEST_HOUR"),
|
||||
},
|
||||
Security: SecurityConfig{
|
||||
SecretKey: viper.GetString("SECRET_KEY"),
|
||||
TokenCacheTTL: 5 * time.Minute,
|
||||
PasswordResetExpiry: 15 * time.Minute,
|
||||
ConfirmationExpiry: 24 * time.Hour,
|
||||
MaxPasswordResetRate: 3,
|
||||
},
|
||||
Storage: StorageConfig{
|
||||
UploadDir: viper.GetString("STORAGE_UPLOAD_DIR"),
|
||||
BaseURL: viper.GetString("STORAGE_BASE_URL"),
|
||||
MaxFileSize: viper.GetInt64("STORAGE_MAX_FILE_SIZE"),
|
||||
AllowedTypes: viper.GetString("STORAGE_ALLOWED_TYPES"),
|
||||
},
|
||||
AppleAuth: AppleAuthConfig{
|
||||
ClientID: viper.GetString("APPLE_CLIENT_ID"),
|
||||
TeamID: viper.GetString("APPLE_TEAM_ID"),
|
||||
},
|
||||
GoogleAuth: GoogleAuthConfig{
|
||||
ClientID: viper.GetString("GOOGLE_CLIENT_ID"),
|
||||
AndroidClientID: viper.GetString("GOOGLE_ANDROID_CLIENT_ID"),
|
||||
IOSClientID: viper.GetString("GOOGLE_IOS_CLIENT_ID"),
|
||||
},
|
||||
AppleIAP: AppleIAPConfig{
|
||||
KeyPath: viper.GetString("APPLE_IAP_KEY_PATH"),
|
||||
KeyID: viper.GetString("APPLE_IAP_KEY_ID"),
|
||||
IssuerID: viper.GetString("APPLE_IAP_ISSUER_ID"),
|
||||
BundleID: viper.GetString("APPLE_IAP_BUNDLE_ID"),
|
||||
Sandbox: viper.GetBool("APPLE_IAP_SANDBOX"),
|
||||
},
|
||||
GoogleIAP: GoogleIAPConfig{
|
||||
ServiceAccountPath: viper.GetString("GOOGLE_IAP_SERVICE_ACCOUNT_PATH"),
|
||||
PackageName: viper.GetString("GOOGLE_IAP_PACKAGE_NAME"),
|
||||
},
|
||||
Stripe: StripeConfig{
|
||||
SecretKey: viper.GetString("STRIPE_SECRET_KEY"),
|
||||
WebhookSecret: viper.GetString("STRIPE_WEBHOOK_SECRET"),
|
||||
PriceMonthly: viper.GetString("STRIPE_PRICE_MONTHLY"),
|
||||
PriceYearly: viper.GetString("STRIPE_PRICE_YEARLY"),
|
||||
},
|
||||
Features: FeatureFlags{
|
||||
PushEnabled: viper.GetBool("FEATURE_PUSH_ENABLED"),
|
||||
EmailEnabled: viper.GetBool("FEATURE_EMAIL_ENABLED"),
|
||||
WebhooksEnabled: viper.GetBool("FEATURE_WEBHOOKS_ENABLED"),
|
||||
OnboardingEmailsEnabled: viper.GetBool("FEATURE_ONBOARDING_EMAILS_ENABLED"),
|
||||
PDFReportsEnabled: viper.GetBool("FEATURE_PDF_REPORTS_ENABLED"),
|
||||
WorkerEnabled: viper.GetBool("FEATURE_WORKER_ENABLED"),
|
||||
},
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if err := validate(cfg); err != nil {
|
||||
return nil, err
|
||||
// Validate required fields
|
||||
if err := validate(cfg); err != nil {
|
||||
loadErr = err
|
||||
// Reset so a subsequent call can retry after env is fixed
|
||||
cfg = nil
|
||||
cfgOnce = sync.Once{}
|
||||
}
|
||||
})
|
||||
|
||||
if loadErr != nil {
|
||||
return nil, loadErr
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
@@ -290,6 +328,7 @@ func setDefaults() {
|
||||
// Server defaults
|
||||
viper.SetDefault("PORT", 8000)
|
||||
viper.SetDefault("DEBUG", false)
|
||||
viper.SetDefault("DEBUG_FIXED_CODES", false) // Separate flag for fixed confirmation codes
|
||||
viper.SetDefault("ALLOWED_HOSTS", "localhost,127.0.0.1")
|
||||
viper.SetDefault("TIMEZONE", "UTC")
|
||||
viper.SetDefault("STATIC_DIR", "/app/static")
|
||||
@@ -347,7 +386,13 @@ func setDefaults() {
|
||||
viper.SetDefault("FEATURE_WORKER_ENABLED", true)
|
||||
}
|
||||
|
||||
// isWeakSecretKey checks if the provided key is a known weak/default value.
|
||||
func isWeakSecretKey(key string) bool {
|
||||
return knownWeakSecretKeys[strings.ToLower(strings.TrimSpace(key))]
|
||||
}
|
||||
|
||||
func validate(cfg *Config) error {
|
||||
// S-08: Validate SECRET_KEY against known weak defaults
|
||||
if cfg.Security.SecretKey == "" {
|
||||
if cfg.Server.Debug {
|
||||
// In debug mode, use a default key with a warning for local development
|
||||
@@ -358,9 +403,12 @@ func validate(cfg *Config) error {
|
||||
// In production, refuse to start without a proper secret key
|
||||
return fmt.Errorf("FATAL: SECRET_KEY environment variable is required in production (DEBUG=false)")
|
||||
}
|
||||
} else if cfg.Security.SecretKey == "change-me-in-production-secret-key-12345" {
|
||||
// Warn if someone explicitly set the well-known debug key
|
||||
fmt.Println("WARNING: SECRET_KEY is set to the well-known debug default. Change it for production use.")
|
||||
} else if isWeakSecretKey(cfg.Security.SecretKey) {
|
||||
if cfg.Server.Debug {
|
||||
fmt.Printf("WARNING: SECRET_KEY is set to a well-known weak value (%q). Change it for production use.\n", cfg.Security.SecretKey)
|
||||
} else {
|
||||
return fmt.Errorf("FATAL: SECRET_KEY is set to a well-known weak value (%q). Use a strong, unique secret in production", cfg.Security.SecretKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Database password might come from DATABASE_URL, don't require it separately
|
||||
@@ -369,6 +417,21 @@ func validate(cfg *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MaskURLCredentials parses a URL and replaces any password with "***".
|
||||
// If parsing fails, it returns the string "<unparseable-url>" to avoid leaking credentials.
|
||||
func MaskURLCredentials(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "<unparseable-url>"
|
||||
}
|
||||
if u.User != nil {
|
||||
if _, hasPassword := u.User.Password(); hasPassword {
|
||||
u.User = url.UserPassword(u.User.Username(), "***")
|
||||
}
|
||||
}
|
||||
return u.Redacted()
|
||||
}
|
||||
|
||||
// DSN returns the database connection string
|
||||
func (d *DatabaseConfig) DSN() string {
|
||||
return fmt.Sprintf(
|
||||
|
||||
Reference in New Issue
Block a user