fix: make environment variables case insensitive where necessary (#954)

fix #935
This commit is contained in:
Elias Schneider
2025-09-17 17:21:54 +02:00
committed by GitHub
parent 68373604dd
commit 99f31a7c26
2 changed files with 105 additions and 147 deletions

View File

@@ -32,17 +32,17 @@ const (
) )
type EnvConfigSchema struct { type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"` AppEnv string `env:"APP_ENV" options:"toLower"`
LogLevel string `env:"LOG_LEVEL"` LogLevel string `env:"LOG_LEVEL" options:"toLower"`
AppURL string `env:"APP_URL"` AppURL string `env:"APP_URL" options:"toLower"`
DbProvider DbProvider `env:"DB_PROVIDER"` DbProvider DbProvider `env:"DB_PROVIDER" options:"toLower"`
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"` DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
UploadPath string `env:"UPLOAD_PATH"` UploadPath string `env:"UPLOAD_PATH"`
KeysPath string `env:"KEYS_PATH"` KeysPath string `env:"KEYS_PATH"`
KeysStorage string `env:"KEYS_STORAGE"` KeysStorage string `env:"KEYS_STORAGE"`
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"` EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
Port string `env:"PORT"` Port string `env:"PORT"`
Host string `env:"HOST"` Host string `env:"HOST" options:"toLower"`
UnixSocket string `env:"UNIX_SOCKET"` UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"` UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"` MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
@@ -112,31 +112,40 @@ func parseEnvConfig() error {
return fmt.Errorf("error parsing env config: %w", err) return fmt.Errorf("error parsing env config: %w", err)
} }
err = resolveFileBasedEnvVariables(&EnvConfig) err = prepareEnvConfig(&EnvConfig)
if err != nil {
return fmt.Errorf("error preparing env config: %w", err)
}
err = validateEnvConfig(&EnvConfig)
if err != nil { if err != nil {
return err return err
} }
// Validate the environment variables return nil
EnvConfig.LogLevel = strings.ToLower(EnvConfig.LogLevel)
if _, err := sloggin.ParseLevel(EnvConfig.LogLevel); err != nil { }
// validateEnvConfig checks the EnvConfig for required fields and valid values
func validateEnvConfig(config *EnvConfigSchema) error {
if _, err := sloggin.ParseLevel(config.LogLevel); err != nil {
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'") return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
} }
switch EnvConfig.DbProvider { switch config.DbProvider {
case DbProviderSqlite: case DbProviderSqlite:
if EnvConfig.DbConnectionString == "" { if config.DbConnectionString == "" {
EnvConfig.DbConnectionString = defaultSqliteConnString config.DbConnectionString = defaultSqliteConnString
} }
case DbProviderPostgres: case DbProviderPostgres:
if EnvConfig.DbConnectionString == "" { if config.DbConnectionString == "" {
return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database") return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
} }
default: default:
return errors.New("invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'") return errors.New("invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
} }
parsedAppUrl, err := url.Parse(EnvConfig.AppURL) parsedAppUrl, err := url.Parse(config.AppURL)
if err != nil { if err != nil {
return errors.New("APP_URL is not a valid URL") return errors.New("APP_URL is not a valid URL")
} }
@@ -145,10 +154,10 @@ func parseEnvConfig() error {
} }
// Derive INTERNAL_APP_URL from APP_URL if not set; validate only when provided // Derive INTERNAL_APP_URL from APP_URL if not set; validate only when provided
if EnvConfig.InternalAppURL == "" { if config.InternalAppURL == "" {
EnvConfig.InternalAppURL = EnvConfig.AppURL config.InternalAppURL = config.AppURL
} else { } else {
parsedInternalAppUrl, err := url.Parse(EnvConfig.InternalAppURL) parsedInternalAppUrl, err := url.Parse(config.InternalAppURL)
if err != nil { if err != nil {
return errors.New("INTERNAL_APP_URL is not a valid URL") return errors.New("INTERNAL_APP_URL is not a valid URL")
} }
@@ -157,25 +166,26 @@ func parseEnvConfig() error {
} }
} }
switch EnvConfig.KeysStorage { switch config.KeysStorage {
// KeysStorage defaults to "file" if empty // KeysStorage defaults to "file" if empty
case "": case "":
EnvConfig.KeysStorage = "file" config.KeysStorage = "file"
case "database": case "database":
if EnvConfig.EncryptionKey == nil { if config.EncryptionKey == nil {
return errors.New("ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database") return errors.New("ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
} }
case "file": case "file":
// All good, these are valid values // All good, these are valid values
default: default:
return fmt.Errorf("invalid value for KEYS_STORAGE: %s", EnvConfig.KeysStorage) return fmt.Errorf("invalid value for KEYS_STORAGE: %s", config.KeysStorage)
} }
return nil return nil
} }
// resolveFileBasedEnvVariables uses reflection to automatically resolve file-based secrets // prepareEnvConfig processes special options for EnvConfig fields
func resolveFileBasedEnvVariables(config *EnvConfigSchema) error { func prepareEnvConfig(config *EnvConfigSchema) error {
val := reflect.ValueOf(config).Elem() val := reflect.ValueOf(config).Elem()
typ := val.Type() typ := val.Type()
@@ -183,23 +193,41 @@ func resolveFileBasedEnvVariables(config *EnvConfigSchema) error {
field := val.Field(i) field := val.Field(i)
fieldType := typ.Field(i) fieldType := typ.Field(i)
optionsTag := fieldType.Tag.Get("options")
options := strings.Split(optionsTag, ",")
for _, option := range options {
switch option {
case "toLower":
if field.Kind() == reflect.String {
field.SetString(strings.ToLower(field.String()))
}
case "file":
err := resolveFileBasedEnvVariable(field, fieldType)
if err != nil {
return err
}
}
}
}
return nil
}
// resolveFileBasedEnvVariable checks if an environment variable with the suffix "_FILE" is set,
// reads the content of the file specified by that variable, and sets the corresponding field's value.
func resolveFileBasedEnvVariable(field reflect.Value, fieldType reflect.StructField) error {
// Only process string and []byte fields // Only process string and []byte fields
isString := field.Kind() == reflect.String isString := field.Kind() == reflect.String
isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8 isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8
if !isString && !isByteSlice { if !isString && !isByteSlice {
continue return nil
}
// Only process fields with the "options" tag set to "file"
optionsTag := fieldType.Tag.Get("options")
if optionsTag != "file" {
continue
} }
// Only process fields with the "env" tag // Only process fields with the "env" tag
envTag := fieldType.Tag.Get("env") envTag := fieldType.Tag.Get("env")
if envTag == "" { if envTag == "" {
continue return nil
} }
envVarName := envTag envVarName := envTag
@@ -211,7 +239,7 @@ func resolveFileBasedEnvVariables(config *EnvConfigSchema) error {
envVarFileName := envVarName + "_FILE" envVarFileName := envVarName + "_FILE"
envVarFileValue := os.Getenv(envVarFileName) envVarFileValue := os.Getenv(envVarFileName)
if envVarFileValue == "" { if envVarFileValue == "" {
continue return nil
} }
fileContent, err := os.ReadFile(envVarFileValue) fileContent, err := os.ReadFile(envVarFileValue)
@@ -224,7 +252,6 @@ func resolveFileBasedEnvVariables(config *EnvConfigSchema) error {
} else { } else {
field.SetBytes(fileContent) field.SetBytes(fileContent)
} }
}
return nil return nil
} }

View File

@@ -17,18 +17,19 @@ func TestParseEnvConfig(t *testing.T) {
t.Run("should parse valid SQLite config correctly", func(t *testing.T) { t.Run("should parse valid SQLite config correctly", func(t *testing.T) {
EnvConfig = defaultConfig() EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite") t.Setenv("DB_PROVIDER", "SQLITE") // should be lowercased automatically
t.Setenv("DB_CONNECTION_STRING", "file:test.db") t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000") t.Setenv("APP_URL", "HTTP://LOCALHOST:3000")
err := parseEnvConfig() err := parseEnvConfig()
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, DbProviderSqlite, EnvConfig.DbProvider) assert.Equal(t, DbProviderSqlite, EnvConfig.DbProvider)
assert.Equal(t, "http://localhost:3000", EnvConfig.AppURL)
}) })
t.Run("should parse valid Postgres config correctly", func(t *testing.T) { t.Run("should parse valid Postgres config correctly", func(t *testing.T) {
EnvConfig = defaultConfig() EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "postgres") t.Setenv("DB_PROVIDER", "POSTGRES")
t.Setenv("DB_CONNECTION_STRING", "postgres://user:pass@localhost/db") t.Setenv("DB_CONNECTION_STRING", "postgres://user:pass@localhost/db")
t.Setenv("APP_URL", "https://example.com") t.Setenv("APP_URL", "https://example.com")
@@ -51,7 +52,6 @@ func TestParseEnvConfig(t *testing.T) {
t.Run("should set default SQLite connection string when DB_CONNECTION_STRING is empty", func(t *testing.T) { t.Run("should set default SQLite connection string when DB_CONNECTION_STRING is empty", func(t *testing.T) {
EnvConfig = defaultConfig() EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite") t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "") // Explicitly empty
t.Setenv("APP_URL", "http://localhost:3000") t.Setenv("APP_URL", "http://localhost:3000")
err := parseEnvConfig() err := parseEnvConfig()
@@ -192,25 +192,25 @@ func TestParseEnvConfig(t *testing.T) {
t.Setenv("DB_PROVIDER", "postgres") t.Setenv("DB_PROVIDER", "postgres")
t.Setenv("DB_CONNECTION_STRING", "postgres://test") t.Setenv("DB_CONNECTION_STRING", "postgres://test")
t.Setenv("APP_URL", "https://prod.example.com") t.Setenv("APP_URL", "https://prod.example.com")
t.Setenv("APP_ENV", "staging") t.Setenv("APP_ENV", "STAGING")
t.Setenv("UPLOAD_PATH", "/custom/uploads") t.Setenv("UPLOAD_PATH", "/custom/uploads")
t.Setenv("KEYS_PATH", "/custom/keys") t.Setenv("KEYS_PATH", "/custom/keys")
t.Setenv("PORT", "8080") t.Setenv("PORT", "8080")
t.Setenv("HOST", "127.0.0.1") t.Setenv("HOST", "LOCALHOST")
t.Setenv("UNIX_SOCKET", "/tmp/app.sock") t.Setenv("UNIX_SOCKET", "/tmp/app.sock")
t.Setenv("MAXMIND_LICENSE_KEY", "test-license") t.Setenv("MAXMIND_LICENSE_KEY", "test-license")
t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb") t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb")
err := parseEnvConfig() err := parseEnvConfig()
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "staging", EnvConfig.AppEnv) assert.Equal(t, "staging", EnvConfig.AppEnv) // lowercased
assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath) assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath)
assert.Equal(t, "8080", EnvConfig.Port) assert.Equal(t, "8080", EnvConfig.Port)
assert.Equal(t, "127.0.0.1", EnvConfig.Host) assert.Equal(t, "localhost", EnvConfig.Host) // lowercased
}) })
} }
func TestResolveFileBasedEnvVariables(t *testing.T) { func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
// Create temporary directory for test files // Create temporary directory for test files
tempDir := t.TempDir() tempDir := t.TempDir()
@@ -225,103 +225,34 @@ func TestResolveFileBasedEnvVariables(t *testing.T) {
err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600) err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600)
require.NoError(t, err) require.NoError(t, err)
// Create a binary file for testing binary data handling
binaryKeyFile := tempDir + "/binary_key.bin" binaryKeyFile := tempDir + "/binary_key.bin"
binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10} binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04}
err = os.WriteFile(binaryKeyFile, binaryKeyContent, 0600) err = os.WriteFile(binaryKeyFile, binaryKeyContent, 0600)
require.NoError(t, err) require.NoError(t, err)
t.Run("should read file content for fields with options:file tag", func(t *testing.T) { t.Run("should process toLower and file options", func(t *testing.T) {
config := defaultConfig() config := defaultConfig()
config.AppEnv = "STAGING"
config.Host = "LOCALHOST"
// Set environment variables pointing to files
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile) t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile) t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
err := resolveFileBasedEnvVariables(&config) err := prepareEnvConfig(&config)
require.NoError(t, err) require.NoError(t, err)
// Verify file contents were read correctly assert.Equal(t, "staging", config.AppEnv)
assert.Equal(t, "localhost", config.Host)
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey) assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, dbConnContent, config.DbConnectionString) assert.Equal(t, dbConnContent, config.DbConnectionString)
}) })
t.Run("should skip fields without options:file tag", func(t *testing.T) {
config := defaultConfig()
originalAppURL := config.AppURL
// Set a file for a field that doesn't have options:file tag
t.Setenv("APP_URL_FILE", "/tmp/nonexistent.txt")
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// AppURL should remain unchanged
assert.Equal(t, originalAppURL, config.AppURL)
})
t.Run("should skip non-string fields", func(t *testing.T) {
// This test verifies that non-string fields are skipped
// We test this indirectly by ensuring the function doesn't error
// when processing the actual EnvConfigSchema which has bool fields
config := defaultConfig()
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
})
t.Run("should skip when _FILE environment variable is not set", func(t *testing.T) {
config := defaultConfig()
originalEncryptionKey := config.EncryptionKey
// Don't set ENCRYPTION_KEY_FILE environment variable
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// EncryptionKey should remain unchanged
assert.Equal(t, originalEncryptionKey, config.EncryptionKey)
})
t.Run("should handle multiple file-based variables simultaneously", func(t *testing.T) {
config := defaultConfig()
// Set multiple file environment variables
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// All should be resolved correctly
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, dbConnContent, config.DbConnectionString)
})
t.Run("should handle mixed file and non-file environment variables", func(t *testing.T) {
config := defaultConfig()
// Set both file and non-file environment variables
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// File-based should be resolved, others should remain as set by env parser
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, "http://localhost:1411", config.AppURL)
})
t.Run("should handle binary data correctly", func(t *testing.T) { t.Run("should handle binary data correctly", func(t *testing.T) {
config := defaultConfig() config := defaultConfig()
// Set environment variable pointing to binary file
t.Setenv("ENCRYPTION_KEY_FILE", binaryKeyFile) t.Setenv("ENCRYPTION_KEY_FILE", binaryKeyFile)
err := resolveFileBasedEnvVariables(&config) err := prepareEnvConfig(&config)
require.NoError(t, err) require.NoError(t, err)
// Verify binary data was read correctly without corruption
assert.Equal(t, binaryKeyContent, config.EncryptionKey) assert.Equal(t, binaryKeyContent, config.EncryptionKey)
}) })
} }