Harden API key file loading
This commit is contained in:
@@ -9,9 +9,18 @@ import (
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
)
|
||||
|
||||
var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
var readSecretsDirectoryEntries = os.ReadDir
|
||||
|
||||
// APIKeyFileByteLimit bounds one credential value read from the configured
|
||||
// secrets directory. API keys are expected to be short single-line values.
|
||||
const APIKeyFileByteLimit int64 = 8 * 1024
|
||||
|
||||
const secretDirectoryPrivateMode os.FileMode = 0o700
|
||||
const secretFilePrivateMode os.FileMode = 0o600
|
||||
|
||||
type secretsLoadStats struct {
|
||||
Dir string
|
||||
@@ -40,9 +49,13 @@ func loadSecretsFromConfig(cfg *config.Config, logger *slog.Logger) (*secretsLoa
|
||||
}
|
||||
resolvedDir = filepath.Clean(resolvedDir)
|
||||
|
||||
entries, err := os.ReadDir(resolvedDir)
|
||||
if err := fileops.ValidateConfinedDirectory(resolvedDir, validateSecretDirectory); err != nil {
|
||||
return nil, fmt.Errorf("API-key loader: validate secrets env_dir: %w", err)
|
||||
}
|
||||
|
||||
entries, err := readSecretsDirectoryEntries(resolvedDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read secrets env_dir %q: %w", resolvedDir, err)
|
||||
return nil, fmt.Errorf("API-key loader: read secrets env_dir %q: %w", resolvedDir, err)
|
||||
}
|
||||
|
||||
stats := &secretsLoadStats{Dir: resolvedDir}
|
||||
@@ -52,24 +65,16 @@ func loadSecretsFromConfig(cfg *config.Config, logger *slog.Logger) (*secretsLoa
|
||||
stats.Skipped++
|
||||
continue
|
||||
}
|
||||
if entry.IsDir() {
|
||||
stats.Skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
secretPath := filepath.Join(resolvedDir, name)
|
||||
bytes, err := os.ReadFile(secretPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read secret file %q: %w", secretPath, err)
|
||||
}
|
||||
value := strings.TrimRight(string(bytes), "\r\n")
|
||||
|
||||
if _, exists := os.LookupEnv(name); exists {
|
||||
stats.PreservedExisting++
|
||||
continue
|
||||
}
|
||||
value, err := readAPIKeyFile(resolvedDir, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Setenv(name, value); err != nil {
|
||||
return nil, fmt.Errorf("set environment variable %q from %q: %w", name, secretPath, err)
|
||||
return nil, fmt.Errorf("API-key loader: set environment variable %q: %w", name, err)
|
||||
}
|
||||
stats.Loaded++
|
||||
}
|
||||
@@ -86,3 +91,31 @@ func loadSecretsFromConfig(cfg *config.Config, logger *slog.Logger) (*secretsLoa
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func validateSecretDirectory(info os.FileInfo) error {
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("secrets env_dir is not a directory")
|
||||
}
|
||||
return validateSecretDirectoryPrivacy(info)
|
||||
}
|
||||
|
||||
func validateSecretFile(info os.FileInfo) error {
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("secret entry is not a regular file")
|
||||
}
|
||||
return validateSecretFilePrivacy(info)
|
||||
}
|
||||
|
||||
func readAPIKeyFile(directory, name string) (string, error) {
|
||||
content, err := fileops.ReadRegularFileUnderRoot(
|
||||
directory,
|
||||
name,
|
||||
APIKeyFileByteLimit,
|
||||
validateSecretDirectory,
|
||||
validateSecretFile,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("API-key loader: read secret file %q with %d-byte limit: %w", name, APIKeyFileByteLimit, err)
|
||||
}
|
||||
return strings.TrimRight(string(content), "\r\n"), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user