mirror of
https://github.com/ivuorinen/f2b.git
synced 2026-01-26 11:24:00 +00:00
* refactor: consolidate test helpers and reduce code duplication
- Fix prealloc lint issue in cmd_logswatch_test.go
- Add validateIPAndJails helper to consolidate IP/jail validation
- Add WithTestRunner/WithTestSudoChecker helpers for cleaner test setup
- Replace setupBasicMockResponses duplicates with StandardMockSetup
- Add SetupStandardResponses/SetupJailResponses to MockRunner
- Delegate cmd context helpers to fail2ban implementations
- Document context wrapper pattern in context_helpers.go
* refactor: consolidate duplicate code patterns across cmd and fail2ban packages
Add helper functions to reduce code duplication found by dupl:
- safeCloseFile/safeCloseReader: centralize file close error logging
- createTimeoutContext: consolidate timeout context creation pattern
- withContextCheck: wrap context cancellation checks
- recordOperationMetrics: unify metrics recording for commands/clients
Also includes Phase 1 consolidations:
- copyBuckets helper for metrics snapshots
- Table-driven context extraction in logging
- processWithValidation helper for IP processors
* refactor: consolidate LoggerInterface by embedding LoggerEntry
Both interfaces had identical method signatures. LoggerInterface now
embeds LoggerEntry to eliminate code duplication.
* refactor: consolidate test framework helpers and fix test patterns
- Add checkJSONFieldValue and failMissingJSONField helpers to reduce
duplication in JSON assertion methods
- Add ParallelTimeout to default test config
- Fix test to use WithTestRunner inside test loop for proper mock scoping
* refactor: unify ban/unban operations with OperationType pattern
Introduce OperationType struct to consolidate duplicate ban/unban logic:
- Add ProcessOperation and ProcessOperationWithContext generic functions
- Add ProcessOperationParallel and ProcessOperationParallelWithContext
- Existing ProcessBan*/ProcessUnban* functions now delegate to generic versions
- Reduces ~120 lines of duplicate code between ban and unban operations
* refactor: consolidate time parsing cache pattern
Add ParseWithLayout method to BoundedTimeCache that consolidates the
cache-lookup-parse-store pattern. FastTimeCache and TimeParsingCache
now delegate to this method instead of duplicating the logic.
* refactor: consolidate command execution patterns in fail2ban
- Add validateCommandExecution helper for command/argument validation
- Add runWithTimerContext helper for timed runner operations
- Add executeIPActionWithContext to unify BanIP/UnbanIP implementations
- Reduces duplicate validation and execution boilerplate
* refactor: consolidate logrus adapter with embedded loggerCore
Introduce loggerCore type that provides the 8 standard logging methods
(Debug, Info, Warn, Error, Debugf, Infof, Warnf, Errorf). Both
logrusAdapter and logrusEntryAdapter now embed this type, eliminating
16 duplicate method implementations.
* refactor: consolidate path validation patterns
- Add validateConfigPathWithFallback helper in cmd/config_utils.go
for the validate-or-fallback-with-logging pattern
- Add validateClientPath helper in fail2ban/helpers.go for client
path validation delegation
* fix: add context cancellation checks to wrapper functions
- wrapWithContext0/1/2 now check ctx.Err() before invoking wrapped function
- WithCommand now validates and trims empty command strings
* refactor: extract formatLatencyBuckets for deterministic metrics output
Add formatLatencyBuckets helper that writes latency bucket distribution
with sorted keys for deterministic output, eliminating duplicate
formatting code for command and client latency buckets.
* refactor: add generic setNestedMapValue helper for mock configuration
Add setNestedMapValue[T] generic helper that consolidates the repeated
pattern of mutex-protected nested map initialization and value setting
used by SetBanError, SetBanResult, SetUnbanError, and SetUnbanResult.
* fix: use cmd.Context() for signal propagation and correct mock status
- ExecuteIPCommand now uses cmd.Context() instead of context.Background()
to inherit Cobra's signal cancellation
- MockRunner.SetupJailResponses uses shared.Fail2BanStatusSuccess ("0")
instead of literal "1" for proper success path simulation
* fix: restore operation-specific log messages in ProcessOperationWithContext
Add back Logger.WithFields().Info(opType.Message) call that was lost
during refactoring. This restores the distinction between ban and unban
operation messages (shared.MsgBanResult vs shared.MsgUnbanResult).
* fix: return aggregated errors from parallel operations
Previously, errors from individual parallel operations were silently
swallowed - converted to status strings but never returned to callers.
Now processOperations collects all errors and returns them aggregated
via errors.Join, allowing callers to distinguish partial failures from
complete success while still receiving all results.
* fix: add input validation to processOperations before parallel execution
Validate IP and jail inputs at the start of processOperations() using
fail2ban.CachedValidateIP and CachedValidateJail. This prevents invalid
or malicious inputs (empty values, path traversal attempts, malformed
IPs) from reaching the operation functions. All validation errors are
aggregated and returned before any operations execute.
319 lines
9.7 KiB
Go
319 lines
9.7 KiB
Go
// Package cmd provides configuration management and validation utilities.
|
|
// This package handles CLI configuration parsing, validation, and security
|
|
// checks to ensure safe operation of f2b commands.
|
|
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/ivuorinen/f2b/fail2ban"
|
|
"github.com/ivuorinen/f2b/shared"
|
|
)
|
|
|
|
// containsPathTraversal performs comprehensive path traversal detection
|
|
// including various encoding techniques and bypass attempts
|
|
func containsPathTraversal(path string) bool {
|
|
if path == "" {
|
|
return false
|
|
}
|
|
|
|
variations := createPathVariations(path)
|
|
return checkPathVariationsForTraversal(variations)
|
|
}
|
|
|
|
// createPathVariations generates different encoded variations of the path to check
|
|
func createPathVariations(path string) []string {
|
|
variations := []string{path}
|
|
|
|
// URL decode the path (handle single and double encoding)
|
|
if decoded, err := url.QueryUnescape(path); err == nil && decoded != path {
|
|
variations = append(variations, decoded)
|
|
// Check for double encoding
|
|
if doubleDecoded, err := url.QueryUnescape(decoded); err == nil && doubleDecoded != decoded {
|
|
variations = append(variations, doubleDecoded)
|
|
}
|
|
}
|
|
|
|
return variations
|
|
}
|
|
|
|
// Cache compiled regex for performance
|
|
var overlongEncodingRegex = regexp.MustCompile(
|
|
`\xc0[\x80-\xbf]|\xe0[\x80-\x9f][\x80-\xbf]|\xf0[\x80-\x8f][\x80-\xbf][\x80-\xbf]`,
|
|
)
|
|
|
|
// checkPathVariationsForTraversal checks all path variations against dangerous patterns
|
|
func checkPathVariationsForTraversal(variations []string) bool {
|
|
allPatterns := getAllDangerousPatterns()
|
|
|
|
for _, variant := range variations {
|
|
if checkSingleVariantForTraversal(variant, allPatterns, overlongEncodingRegex) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// getAllDangerousPatterns returns all dangerous path traversal patterns
|
|
func getAllDangerousPatterns() map[string][]string {
|
|
return map[string][]string{
|
|
"basic": {
|
|
"..", "../", "..\\", "..%2f", "..%2F", "..%5c", "..%5C",
|
|
},
|
|
"urlEncoded": {
|
|
"%2e%2e", "%2E%2E", "%2e%2E", "%2E%2e",
|
|
"%252e%252e", "%252E%252E", "%25252e%25252e",
|
|
},
|
|
"unicode": {
|
|
"\\u002e\\u002e", "\\u00002e\\u00002e", "..",
|
|
},
|
|
"mixed": {
|
|
"..%00", ".%2e", "%2e.", "...//", "..;/", "..%3b",
|
|
},
|
|
}
|
|
}
|
|
|
|
// checkSingleVariantForTraversal checks a single path variant against all patterns
|
|
func checkSingleVariantForTraversal(variant string, patterns map[string][]string, overlongRegex *regexp.Regexp) bool {
|
|
lowerVariant := strings.ToLower(variant)
|
|
|
|
// Check all pattern categories
|
|
for _, patternList := range patterns {
|
|
for _, pattern := range patternList {
|
|
if containsPattern(variant, lowerVariant, pattern) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for UTF-8 overlong encodings
|
|
if overlongRegex.MatchString(variant) {
|
|
return true
|
|
}
|
|
|
|
// Check for null byte injection combined with path traversal
|
|
if containsNullByteInjection(variant, lowerVariant) {
|
|
return true
|
|
}
|
|
|
|
// Check for invalid UTF-8 sequences
|
|
if !utf8.ValidString(variant) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// containsPattern checks if a variant contains a dangerous pattern
|
|
func containsPattern(variant, lowerVariant, pattern string) bool {
|
|
// For Unicode patterns, check both original and lowercase
|
|
if strings.Contains(pattern, "\\u") || strings.Contains(pattern, "\\x") {
|
|
return strings.Contains(variant, pattern) || strings.Contains(lowerVariant, strings.ToLower(pattern))
|
|
}
|
|
// For other patterns, use case-insensitive check
|
|
return strings.Contains(lowerVariant, strings.ToLower(pattern))
|
|
}
|
|
|
|
// containsNullByteInjection checks for null byte injection with path traversal
|
|
func containsNullByteInjection(variant, lowerVariant string) bool {
|
|
return strings.Contains(variant, "\x00") &&
|
|
(strings.Contains(variant, "..") || strings.Contains(lowerVariant, "%2e"))
|
|
}
|
|
|
|
// validateConfigPath validates directory paths from configuration
|
|
func validateConfigPath(path, pathType string) (string, error) {
|
|
if path == "" {
|
|
return "", fmt.Errorf("%s path cannot be empty", pathType)
|
|
}
|
|
|
|
// Comprehensive path traversal detection
|
|
if containsPathTraversal(path) {
|
|
return "", fmt.Errorf("%s path contains path traversal: %s", pathType, path)
|
|
}
|
|
|
|
// Check for null bytes
|
|
if strings.Contains(path, "\x00") {
|
|
return "", fmt.Errorf("%s path contains null byte: %s", pathType, path)
|
|
}
|
|
|
|
// Resolve to absolute path
|
|
absPath, err := filepath.Abs(filepath.Clean(path))
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid %s path: %w", pathType, err)
|
|
}
|
|
|
|
// Check path length (reasonable limit)
|
|
if len(absPath) > 4096 {
|
|
return "", fmt.Errorf("%s path too long: %d characters", pathType, len(absPath))
|
|
}
|
|
|
|
// Validate that it's a reasonable system path
|
|
if !isReasonableSystemPath(absPath, pathType) {
|
|
return "", fmt.Errorf("%s path not in expected system location: %s", pathType, absPath)
|
|
}
|
|
|
|
return absPath, nil
|
|
}
|
|
|
|
// validateConfigPathWithFallback validates a config path and returns the fallback if validation fails.
|
|
// This consolidates the common pattern of validate-or-fallback-with-logging used for config paths.
|
|
func validateConfigPathWithFallback(path, pathType, defaultPath, errorMsg string) string {
|
|
validated, err := validateConfigPath(path, pathType)
|
|
if err != nil {
|
|
Logger.WithError(err).WithField(shared.LogFieldPath, path).Error(errorMsg)
|
|
return defaultPath
|
|
}
|
|
return validated
|
|
}
|
|
|
|
// isReasonableSystemPath checks if a path is in a reasonable system location
|
|
func isReasonableSystemPath(path, pathType string) bool {
|
|
// Allow common system directories based on path type
|
|
var allowedPrefixes []string
|
|
switch pathType {
|
|
case shared.PathTypeLog:
|
|
allowedPrefixes = fail2ban.GetLogAllowedPaths()
|
|
case shared.PathTypeFilter:
|
|
allowedPrefixes = fail2ban.GetFilterAllowedPaths()
|
|
default:
|
|
return false
|
|
}
|
|
|
|
for _, prefix := range allowedPrefixes {
|
|
if strings.HasPrefix(path, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// NewConfigFromEnv builds Config from environment variables with defaults and validation.
|
|
func NewConfigFromEnv() Config {
|
|
cfg := Config{}
|
|
|
|
// Get and validate log directory
|
|
logDir := os.Getenv("F2B_LOG_DIR")
|
|
if logDir == "" {
|
|
logDir = shared.DefaultLogDir
|
|
}
|
|
cfg.LogDir = validateConfigPathWithFallback(
|
|
logDir, shared.PathTypeLog, shared.DefaultLogDir,
|
|
"Invalid log directory from environment",
|
|
)
|
|
|
|
// Get and validate filter directory
|
|
filterDir := os.Getenv("F2B_FILTER_DIR")
|
|
if filterDir == "" {
|
|
filterDir = shared.DefaultFilterDir
|
|
}
|
|
cfg.FilterDir = validateConfigPathWithFallback(
|
|
filterDir, shared.PathTypeFilter, shared.DefaultFilterDir,
|
|
"Invalid filter directory from environment",
|
|
)
|
|
|
|
// Configure timeouts from environment variables
|
|
cfg.CommandTimeout = parseTimeoutFromEnv("F2B_COMMAND_TIMEOUT", shared.DefaultCommandTimeout)
|
|
cfg.FileTimeout = parseTimeoutFromEnv("F2B_FILE_TIMEOUT", shared.DefaultFileTimeout)
|
|
cfg.ParallelTimeout = parseTimeoutFromEnv("F2B_PARALLEL_TIMEOUT", shared.DefaultParallelTimeout)
|
|
|
|
cfg.Format = PlainFormat
|
|
return cfg
|
|
}
|
|
|
|
// parseTimeoutFromEnv parses timeout duration from environment variable with fallback
|
|
func parseTimeoutFromEnv(envVar string, defaultTimeout time.Duration) time.Duration {
|
|
envValue := os.Getenv(envVar)
|
|
if envValue == "" {
|
|
return defaultTimeout
|
|
}
|
|
|
|
// Try parsing as duration first (e.g., "30s", "1m30s")
|
|
if duration, err := time.ParseDuration(envValue); err == nil {
|
|
if duration <= 0 {
|
|
Logger.WithField(shared.LogFieldEnvVar, envVar).WithField(shared.LogFieldValue, envValue).
|
|
Warn(shared.MsgInvalidTimeout)
|
|
return defaultTimeout
|
|
}
|
|
return duration
|
|
}
|
|
|
|
// Try parsing as seconds (for backward compatibility)
|
|
if seconds, err := strconv.Atoi(envValue); err == nil {
|
|
if seconds <= 0 {
|
|
Logger.WithField(shared.LogFieldEnvVar, envVar).WithField(shared.LogFieldValue, envValue).
|
|
Warn(shared.MsgInvalidTimeout)
|
|
return defaultTimeout
|
|
}
|
|
return time.Duration(seconds) * time.Second
|
|
}
|
|
|
|
Logger.WithField(shared.LogFieldEnvVar, envVar).WithField(shared.LogFieldValue, envValue).
|
|
Warn("Failed to parse timeout value, using default")
|
|
return defaultTimeout
|
|
}
|
|
|
|
// ValidateConfig performs comprehensive validation of the Config struct
|
|
func (c *Config) ValidateConfig() error {
|
|
var errors []string
|
|
|
|
// Validate LogDir
|
|
if c.LogDir == "" {
|
|
errors = append(errors, "log directory cannot be empty")
|
|
} else if _, err := validateConfigPath(c.LogDir, shared.PathTypeLog); err != nil {
|
|
errors = append(errors, fmt.Sprintf("invalid log directory: %v", err))
|
|
}
|
|
|
|
// Validate FilterDir
|
|
if c.FilterDir == "" {
|
|
errors = append(errors, "filter directory cannot be empty")
|
|
} else if _, err := validateConfigPath(c.FilterDir, shared.PathTypeFilter); err != nil {
|
|
errors = append(errors, fmt.Sprintf("invalid filter directory: %v", err))
|
|
}
|
|
|
|
// Validate Format
|
|
validFormats := map[string]bool{PlainFormat: true, JSONFormat: true}
|
|
if !validFormats[c.Format] {
|
|
errors = append(errors, fmt.Sprintf("invalid format '%s', must be 'plain' or 'json'", c.Format))
|
|
}
|
|
|
|
// Validate Timeouts
|
|
if c.CommandTimeout <= 0 {
|
|
errors = append(errors, "command timeout must be positive")
|
|
} else if c.CommandTimeout > shared.MaxCommandTimeout {
|
|
errors = append(errors, "command timeout too large (max 10 minutes)")
|
|
}
|
|
|
|
if c.FileTimeout <= 0 {
|
|
errors = append(errors, "file timeout must be positive")
|
|
} else if c.FileTimeout > shared.MaxFileTimeout {
|
|
errors = append(errors, "file timeout too large (max 5 minutes)")
|
|
}
|
|
|
|
if c.ParallelTimeout <= 0 {
|
|
errors = append(errors, "parallel timeout must be positive")
|
|
} else if c.ParallelTimeout > shared.MaxParallelTimeout {
|
|
errors = append(errors, "parallel timeout too large (max 30 minutes)")
|
|
}
|
|
|
|
// Check timeout relationships
|
|
if c.ParallelTimeout < c.CommandTimeout {
|
|
errors = append(errors, "parallel timeout should be >= command timeout")
|
|
}
|
|
|
|
if len(errors) > 0 {
|
|
return fmt.Errorf("configuration validation failed: %s", strings.Join(errors, "; "))
|
|
}
|
|
|
|
return nil
|
|
}
|