From 8249a1110cd073eb175a6875dffc435182e58992 Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 6 Nov 2026 18:33:31 -0800 Subject: [PATCH] fmt and feedback comments, no code changes --- internal/config/config.go | 485 +++++++++++++++++++++--------- internal/lookup/file.go | 34 ++- internal/requestlog/requestlog.go | 42 ++- 3 files changed, 413 insertions(+), 148 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 22fb33f..1c8d403 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,9 @@ // the image runs. package config +// FIXME: the rproxy config is in rproxy.go, the servewaf is in config.go - +// wrong and inconsistent. + import ( "crypto/x509" "errors" @@ -363,13 +366,16 @@ var ( "is not a header name such as accept-language") errHeaderTakenOut = errors.New( "is taken out of every request by Go's HTTP server, so it can never " + - "be logged") + "be logged", + ) errOnBothLists = errors.New("is in SWWAF_DENIED_COUNTRIES too") errNotLookupSource = errors.New("is not geojs, file or off") errNeedsLookups = errors.New("it needs each client looked up") - errNeedsDBPath = errors.New("it names the file to look clients up in") - errDBPathUnused = errors.New("only file reads it") - errOver64K = errors.New( + errNeedsDBPath = errors.New( + "it names the file to look clients up in", + ) + errDBPathUnused = errors.New("only file reads it") + errOver64K = errors.New( "is more than 64K, the most servewaf gets of a body") errNotCommand = errors.New("is not rproxy or servewaf") errNotDurationAboveZero = errors.New( @@ -394,22 +400,29 @@ var ( errNotPathPrefix = errors.New( "is not a path prefix starting with /, such as /assets/") errNotTrapPath = errors.New( - "is not a path starting with / and without a ?, such as /wp-login.php") + "is not a path starting with / and without a ?, such as /wp-login.php", + ) errNotWAFMode = errors.New("is not off, detect or block") errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4") errNotRuleID = errors.New( - "is not the id of a Core Rule Set rule, a whole number such as 942100") + "is not the id of a Core Rule Set rule, a whole number such as 942100", + ) errSetupRuleID = errors.New( "is from 900000 to 900999, the ids of the rules that set the Core Rule Set " + - "up and of smallwebwaf's own, which cannot be switched off") + "up and of smallwebwaf's own, which cannot be switched off", + ) errNotBoolean = errors.New("is not true or false") errNotLogRemoteURL = errors.New( "is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " + - "and nothing more, such as syslog+tls://logs.example:6514") + "and nothing more, such as syslog+tls://logs.example:6514", + ) errNoCertificate = errors.New("holds no PEM certificate") - errNotFacility = errors.New("is not a syslog facility such as local0 or daemon") - errNotAppName = errors.New( - "is not 1 to 48 printable ASCII characters without a space, such as gitea") + errNotFacility = errors.New( + "is not a syslog facility such as local0 or daemon", + ) + errNotAppName = errors.New( + "is not 1 to 48 printable ASCII characters without a space, such as gitea", + ) errSetTwice = errors.New("set only one of them") errNotWebhookURL = errors.New( "is not an http or https URL without a user or a fragment, " + @@ -418,35 +431,50 @@ var ( "is not a header name followed by : and the header's value, " + "such as Authorization:Bearer ") errControlCharacter = errors.New( - "holds a control character, such as the carriage return of a Windows line end") + "holds a control character, such as the carriage return of a Windows line end", + ) errNotAlertEvent = errors.New( "is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " + "source_failure or file_error") - errNotNumberOrOff = errors.New("is not a whole number above zero, such as 60, or off") + errNotNumberOrOff = errors.New( + "is not a whole number above zero, such as 60, or off", + ) errNotUTF8 = errors.New("is not valid UTF-8") errNotASN = errors.New("is not an AS number such as AS64496") errNotPercentItem = errors.New( "is not a code, : and a percentage, such as AS64496:50 or cn:25") - errNotPercent = errors.New("is not a percentage, a whole number from 0 to 100") + errNotPercent = errors.New( + "is not a percentage, a whole number from 0 to 100", + ) errListedTwice = errors.New("is listed twice") errNotListURL = errors.New( "is not an http or https URL without a user or a fragment, " + "such as https://www.spamhaus.org/drop/drop.txt") errInBlocklistURLs = errors.New("is in SWWAF_BLOCKLIST_URLS too") - errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h") - errNotAction = errors.New( + errNotAnHourOrMore = errors.New( + "is not a duration of 1h or more, such as 24h", + ) + errNotAction = errors.New( "is not deny, limit: such as limit:25, or log") - errNotZone = errors.New("is not a DNS zone such as dnsbl.dronebl.org") - errZoneTooLong = errors.New("is longer than 189 characters, too long for the " + - "names IPv6 clients are asked about by") - errNotResolver = errors.New("is not an IP address with an optional port, " + - "such as 192.0.2.53 or [2001:db8::53]:5353") + errNotZone = errors.New( + "is not a DNS zone such as dnsbl.dronebl.org", + ) + errZoneTooLong = errors.New( + "is longer than 189 characters, too long for the " + + "names IPv6 clients are asked about by", + ) + errNotResolver = errors.New( + "is not an IP address with an optional port, " + + "such as 192.0.2.53 or [2001:db8::53]:5353", + ) errNotLAPIURL = errors.New( "is not an http or https URL without a user or a fragment, " + "such as http://172.17.0.1:8080") errNeedsLAPIKey = errors.New("the engine answers no request without it") - errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL") - errAnotherList = errors.New( + errLAPIKeyUnused = errors.New( + "it is sent only to the engine at that URL", + ) + errAnotherList = errors.New( "is in SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too") ) @@ -457,97 +485,208 @@ var ( // setting that is set but invalid is an error that names it. // //nolint:funlen // one line for each setting, a list that grows with them -func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) { +func FromEnvironment( + lookupEnv func(string) (string, bool), +) (*Config, error) { env := &environment{lookupEnv: lookupEnv} cfg := &Config{ - ListenAddr: env.address("SWWAF_LISTEN_ADDR"), - InstanceName: env.instanceName(), - Observe: env.observe("SWWAF_MODE", "enforce"), - IPv6GroupPrefix: env.ipv6GroupPrefix("SWWAF_IPV6_GROUP_PREFIX", "64"), - MaxTrackedClients: env.numberNotOff("SWWAF_MAX_TRACKED_CLIENTS", "20000"), - AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""), - RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""), - DenyNets: env.netblocks("SWWAF_DENY_NETS", ""), - RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "120"), - RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "1500"), - RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "6000"), - RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""), - BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "off"), - BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "50G"), - BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "100G"), - BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"), - LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"), - LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""), - LookupTimeout: env.durationNotOff("SWWAF_LOOKUP_TIMEOUT", "800ms"), - AddLookupHeaders: env.boolean("SWWAF_ADD_LOOKUP_HEADERS", "false"), - DeniedCountries: env.countries("SWWAF_DENIED_COUNTRIES", ""), + ListenAddr: env.address("SWWAF_LISTEN_ADDR"), + InstanceName: env.instanceName(), + Observe: env.observe("SWWAF_MODE", "enforce"), + IPv6GroupPrefix: env.ipv6GroupPrefix( + "SWWAF_IPV6_GROUP_PREFIX", + "64", + ), + MaxTrackedClients: env.numberNotOff( + "SWWAF_MAX_TRACKED_CLIENTS", + "20000", + ), + AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""), + RateLimitExemptNets: env.netblocks( + "SWWAF_RATE_LIMIT_EXEMPT_NETS", + "", + ), + DenyNets: env.netblocks("SWWAF_DENY_NETS", ""), + RateLimitPerMinute: env.count( + "SWWAF_RATE_LIMIT_PER_MINUTE", + "120", + ), + RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "1500"), + RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "6000"), + RateLimitExemptPaths: env.pathPrefixes( + "SWWAF_RATE_LIMIT_EXEMPT_PATHS", + "", + ), + BytesLimitPerMinute: env.size( + "SWWAF_BYTES_LIMIT_PER_MINUTE", + "off", + ), + BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "50G"), + BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "100G"), + BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"), + LookupSource: env.lookupSource( + "SWWAF_LOOKUP_SOURCE", + "geojs", + ), + LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""), + LookupTimeout: env.durationNotOff( + "SWWAF_LOOKUP_TIMEOUT", + "800ms", + ), + AddLookupHeaders: env.boolean( + "SWWAF_ADD_LOOKUP_HEADERS", + "false", + ), + DeniedCountries: env.countries("SWWAF_DENIED_COUNTRIES", ""), ExclusivelyAllowedCountries: env.countries( "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""), - ASNLimitPercent: env.percents("SWWAF_ASN_LIMIT_PERCENT", ParseASN), - CountryLimitPercent: env.percents("SWWAF_COUNTRY_LIMIT_PERCENT", parseCountry), - ASNBytesPercent: env.percents("SWWAF_ASN_BYTES_PERCENT", ParseASN), - CountryBytesPercent: env.percents("SWWAF_COUNTRY_BYTES_PERCENT", parseCountry), - UnknownLimitPercent: env.percent("SWWAF_UNKNOWN_LIMIT_PERCENT", "100"), - ASNLimitPercentURL: env.listURL("SWWAF_ASN_LIMIT_PERCENT_URL"), - BlocklistURLs: env.listURLs("SWWAF_BLOCKLIST_URLS"), - BlocklistRefresh: env.refresh("SWWAF_BLOCKLIST_REFRESH", "24h"), - DNSBLZones: env.zones("SWWAF_DNSBL_ZONES"), - DNSBLResolver: env.resolver("SWWAF_DNSBL_RESOLVER"), - AbuseIPDBKey: env.secret("SWWAF_ABUSEIPDB_KEY"), - AbuseIPDBMinScore: env.percent("SWWAF_ABUSEIPDB_MIN_SCORE", "75"), - AbuseIPDBDailyBudget: env.numberNotOff("SWWAF_ABUSEIPDB_DAILY_BUDGET", "900"), - ReputationCacheTTL: env.durationNotOff("SWWAF_REPUTATION_CACHE_TTL", "24h"), - ReputationTimeout: env.durationNotOff("SWWAF_REPUTATION_TIMEOUT", "2s"), - CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWWAF_CROWDSEC_LAPI_URL"), - CrowdSecKey: env.secret("SWWAF_CROWDSEC_LAPI_KEY"), - BanResponse: env.banResponse("SWWAF_BAN_RESPONSE", "403"), - LimitBanDuration: env.durationNotOff("SWWAF_LIMIT_BAN_DURATION", "1h"), - LimitBanRepeatWindow: env.durationNotOff("SWWAF_LIMIT_BAN_REPEAT_WINDOW", "24h"), - MaxBanDuration: env.durationNotOff("SWWAF_MAX_BAN_DURATION", "7d"), - AttackBanDuration: env.durationNotOff("SWWAF_ATTACK_BAN_DURATION", "7d"), - MaxBans: env.numberNotOff("SWWAF_MAX_BANS", "5000"), - BanScopeV4Prefix: env.v4Prefix("SWWAF_BAN_SCOPE_V4_PREFIX", "32"), - StateDir: env.absolutePath("SWWAF_STATE_DIR", "/var/lib/smallwebwaf"), - StateWriteDelay: env.durationNotOff("SWWAF_STATE_WRITE_DELAY", "10s"), - StateCounterInterval: env.durationNotOff("SWWAF_STATE_COUNTER_INTERVAL", "15m"), - LogRequestHeaders: env.headerNames("SWWAF_LOG_REQUEST_HEADERS", - "accept,accept-language,accept-encoding,content-type,origin,range"), - LogLevel: env.logLevel("SWWAF_LOG_LEVEL", "info"), - AdminToken: env.token("SWWAF_ADMIN_TOKEN"), - MetricsToken: env.token("SWWAF_METRICS_TOKEN"), - MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"), - RulesDir: env.value("SWWAF_RULES_DIR", "/etc/smallwebwaf/rules.d"), - RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"), - WAFMode: env.wafMode("SWWAF_WAF_MODE", WAFModeBlock), - WAFParanoiaLevel: env.paranoiaLevel("SWWAF_WAF_PARANOIA_LEVEL", "1"), - WAFAnomalyThreshold: env.numberOrOff("SWWAF_WAF_ANOMALY_THRESHOLD", "5"), + ASNLimitPercent: env.percents( + "SWWAF_ASN_LIMIT_PERCENT", + ParseASN, + ), + CountryLimitPercent: env.percents( + "SWWAF_COUNTRY_LIMIT_PERCENT", + parseCountry, + ), + ASNBytesPercent: env.percents( + "SWWAF_ASN_BYTES_PERCENT", + ParseASN, + ), + CountryBytesPercent: env.percents( + "SWWAF_COUNTRY_BYTES_PERCENT", + parseCountry, + ), + UnknownLimitPercent: env.percent( + "SWWAF_UNKNOWN_LIMIT_PERCENT", + "100", + ), + ASNLimitPercentURL: env.listURL("SWWAF_ASN_LIMIT_PERCENT_URL"), + BlocklistURLs: env.listURLs("SWWAF_BLOCKLIST_URLS"), + BlocklistRefresh: env.refresh("SWWAF_BLOCKLIST_REFRESH", "24h"), + DNSBLZones: env.zones("SWWAF_DNSBL_ZONES"), + DNSBLResolver: env.resolver("SWWAF_DNSBL_RESOLVER"), + AbuseIPDBKey: env.secret("SWWAF_ABUSEIPDB_KEY"), + AbuseIPDBMinScore: env.percent("SWWAF_ABUSEIPDB_MIN_SCORE", "75"), + AbuseIPDBDailyBudget: env.numberNotOff( + "SWWAF_ABUSEIPDB_DAILY_BUDGET", + "900", + ), + ReputationCacheTTL: env.durationNotOff( + "SWWAF_REPUTATION_CACHE_TTL", + "24h", + ), + ReputationTimeout: env.durationNotOff( + "SWWAF_REPUTATION_TIMEOUT", + "2s", + ), + CrowdSecDecisionsURL: env.crowdSecDecisionsURL( + "SWWAF_CROWDSEC_LAPI_URL", + ), + CrowdSecKey: env.secret("SWWAF_CROWDSEC_LAPI_KEY"), + BanResponse: env.banResponse("SWWAF_BAN_RESPONSE", "403"), + LimitBanDuration: env.durationNotOff( + "SWWAF_LIMIT_BAN_DURATION", + "1h", + ), + LimitBanRepeatWindow: env.durationNotOff( + "SWWAF_LIMIT_BAN_REPEAT_WINDOW", + "24h", + ), + MaxBanDuration: env.durationNotOff( + "SWWAF_MAX_BAN_DURATION", + "7d", + ), + AttackBanDuration: env.durationNotOff( + "SWWAF_ATTACK_BAN_DURATION", + "7d", + ), + MaxBans: env.numberNotOff("SWWAF_MAX_BANS", "5000"), + BanScopeV4Prefix: env.v4Prefix( + "SWWAF_BAN_SCOPE_V4_PREFIX", + "32", + ), + StateDir: env.absolutePath( + "SWWAF_STATE_DIR", + "/var/lib/smallwebwaf", + ), + StateWriteDelay: env.durationNotOff( + "SWWAF_STATE_WRITE_DELAY", + "10s", + ), + StateCounterInterval: env.durationNotOff( + "SWWAF_STATE_COUNTER_INTERVAL", + "15m", + ), + LogRequestHeaders: env.headerNames( + "SWWAF_LOG_REQUEST_HEADERS", + "accept,accept-language,accept-encoding,content-type,origin,range", + ), + LogLevel: env.logLevel("SWWAF_LOG_LEVEL", "info"), + AdminToken: env.token("SWWAF_ADMIN_TOKEN"), + MetricsToken: env.token("SWWAF_METRICS_TOKEN"), + MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"), + RulesDir: env.value( + "SWWAF_RULES_DIR", + "/etc/smallwebwaf/rules.d", + ), + RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"), + WAFMode: env.wafMode("SWWAF_WAF_MODE", WAFModeBlock), + WAFParanoiaLevel: env.paranoiaLevel( + "SWWAF_WAF_PARANOIA_LEVEL", + "1", + ), + WAFAnomalyThreshold: env.numberOrOff( + "SWWAF_WAF_ANOMALY_THRESHOLD", + "5", + ), WAFDisabledRules: env.ruleIDs("SWWAF_WAF_DISABLED_RULES", "920340,920420,920440,920640,930130,930140"), - WAFExemptPaths: env.pathPrefixes("SWWAF_WAF_EXEMPT_PATHS", ""), - WAFBodyLimit: env.wafBodyLimit("SWWAF_WAF_BODY_LIMIT", off), - TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"), - ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"), - LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"), - LogRemoteTLSCAs: env.certificates("SWWAF_LOG_REMOTE_TLS_CA_FILE"), - LogRemoteBuffer: env.numberNotOff("SWWAF_LOG_REMOTE_BUFFER", "10000"), - LogRemoteFacility: env.facility("SWWAF_LOG_REMOTE_FACILITY", "local0"), - AlertWebhookURL: env.webhookURL("SWWAF_ALERT_WEBHOOK_URL"), - AlertWebhookHeaders: env.webhookHeaders("SWWAF_ALERT_WEBHOOK_HEADERS"), - AlertSlackWebhookURL: env.webhookURL("SWWAF_ALERT_SLACK_WEBHOOK_URL"), - AlertNtfyURL: env.webhookURL("SWWAF_ALERT_NTFY_URL"), - AlertNtfyToken: env.secret("SWWAF_ALERT_NTFY_TOKEN"), + WAFExemptPaths: env.pathPrefixes("SWWAF_WAF_EXEMPT_PATHS", ""), + WAFBodyLimit: env.wafBodyLimit("SWWAF_WAF_BODY_LIMIT", off), + TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"), + ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"), + LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"), + LogRemoteTLSCAs: env.certificates( + "SWWAF_LOG_REMOTE_TLS_CA_FILE", + ), + LogRemoteBuffer: env.numberNotOff( + "SWWAF_LOG_REMOTE_BUFFER", + "10000", + ), + LogRemoteFacility: env.facility( + "SWWAF_LOG_REMOTE_FACILITY", + "local0", + ), + AlertWebhookURL: env.webhookURL("SWWAF_ALERT_WEBHOOK_URL"), + AlertWebhookHeaders: env.webhookHeaders( + "SWWAF_ALERT_WEBHOOK_HEADERS", + ), + AlertSlackWebhookURL: env.webhookURL( + "SWWAF_ALERT_SLACK_WEBHOOK_URL", + ), + AlertNtfyURL: env.webhookURL("SWWAF_ALERT_NTFY_URL"), + AlertNtfyToken: env.secret("SWWAF_ALERT_NTFY_TOKEN"), AlertEvents: env.alertEvents("SWWAF_ALERT_EVENTS", strings.Join(alerts.Events(), ",")), - AlertCooldown: env.duration("SWWAF_ALERT_COOLDOWN", "15m"), - AlertMaxPerHour: env.numberOrOff("SWWAF_ALERT_MAX_PER_HOUR", "60"), - AnomalyClient: env.thresholds("SWWAF_ANOMALY_CLIENT_"), - AnomalyNet: env.thresholds("SWWAF_ANOMALY_NET_"), - AnomalyASN: env.thresholds("SWWAF_ANOMALY_ASN_"), - AnomalyTotal: env.thresholds("SWWAF_ANOMALY_TOTAL_"), - AnomalyWatch: env.thresholds("SWWAF_WATCH_"), - AnomalyNetV4Prefix: env.v4Prefix("SWWAF_ANOMALY_NET_V4_PREFIX", "24"), - AnomalyNetV6Prefix: env.v6Prefix("SWWAF_ANOMALY_NET_V6_PREFIX", "48"), - WatchNets: env.namedNetblocks("SWWAF_WATCH_NETS"), + AlertCooldown: env.duration("SWWAF_ALERT_COOLDOWN", "15m"), + AlertMaxPerHour: env.numberOrOff( + "SWWAF_ALERT_MAX_PER_HOUR", + "60", + ), + AnomalyClient: env.thresholds("SWWAF_ANOMALY_CLIENT_"), + AnomalyNet: env.thresholds("SWWAF_ANOMALY_NET_"), + AnomalyASN: env.thresholds("SWWAF_ANOMALY_ASN_"), + AnomalyTotal: env.thresholds("SWWAF_ANOMALY_TOTAL_"), + AnomalyWatch: env.thresholds("SWWAF_WATCH_"), + AnomalyNetV4Prefix: env.v4Prefix( + "SWWAF_ANOMALY_NET_V4_PREFIX", + "24", + ), + AnomalyNetV6Prefix: env.v6Prefix( + "SWWAF_ANOMALY_NET_V6_PREFIX", + "48", + ), + WatchNets: env.namedNetblocks("SWWAF_WATCH_NETS"), } cfg.LogRemoteAppName = env.appName("SWWAF_LOG_REMOTE_APP_NAME", @@ -584,14 +723,20 @@ func Command(lookupEnv func(string) (string, bool)) (string, error) { case "servewaf": return command, nil default: - return "", fmt.Errorf("SMALLWEBWAF_COMMAND: %q %w", command, errNotCommand) + return "", fmt.Errorf( + "SMALLWEBWAF_COMMAND: %q %w", + command, + errNotCommand, + ) } } // ServeWAFListenAddr reads only SWWAF_LISTEN_ADDR, which may be given as a // file, as FromEnvironment does. The health check in servewaf's container needs // no other setting, so it reads no other, nor a file that another names. -func ServeWAFListenAddr(lookupEnv func(string) (string, bool)) (string, error) { +func ServeWAFListenAddr( + lookupEnv func(string) (string, bool), +) (string, error) { env := &environment{lookupEnv: lookupEnv} listenAddr := env.address("SWWAF_LISTEN_ADDR") @@ -653,14 +798,19 @@ func (e *environment) lookup(name string) (string, bool) { } if set { - e.check(name, fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice)) + e.check( + name, + fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice), + ) return value, set } e.settings = append(e.settings, slog.String(fileName, path)) - contents, err := os.ReadFile(path) //nolint:gosec // a file the admin names + contents, err := os.ReadFile( + path, + ) //nolint:gosec // a file the admin names if err != nil { e.check(fileName, fmt.Errorf("cannot be read: %w", err)) @@ -911,7 +1061,10 @@ func (e *environment) zones(name string) []string { logged[i] = MaskZoneKey(zone) } - e.settings = append(e.settings, slog.String(name, strings.Join(logged, ","))) + e.settings = append( + e.settings, + slog.String(name, strings.Join(logged, ",")), + ) return zones } @@ -963,10 +1116,15 @@ func (e *environment) checkLookupDBPath(cfg *Config) { switch { case cfg.LookupSource == fileSource && cfg.LookupDBPath == "": e.check("SWWAF_LOOKUP_SOURCE", fmt.Errorf( - "is file while SWWAF_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath)) + "is file while SWWAF_LOOKUP_DB_PATH is unset; %w", + errNeedsDBPath, + )) case cfg.LookupSource != fileSource && cfg.LookupDBPath != "": e.check("SWWAF_LOOKUP_DB_PATH", fmt.Errorf( - "is set while SWWAF_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused)) + "is set while SWWAF_LOOKUP_SOURCE is %s; %w", + cfg.LookupSource, + errDBPathUnused, + )) } } @@ -1007,8 +1165,11 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) { {"SWWAF_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0}, } { if setting.set { - e.check(setting.name, fmt.Errorf("is set while SWWAF_LOOKUP_SOURCE is off; %w", - errNeedsLookups)) + e.check( + setting.name, + fmt.Errorf("is set while SWWAF_LOOKUP_SOURCE is off; %w", + errNeedsLookups), + ) } } } @@ -1031,14 +1192,21 @@ func (e *environment) checkCrowdSec(cfg *Config) { switch { case decisionsURL != "" && cfg.CrowdSecKey == "": e.check("SWWAF_CROWDSEC_LAPI_URL", fmt.Errorf( - "is set while SWWAF_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey)) + "is set while SWWAF_CROWDSEC_LAPI_KEY is unset; %w", + errNeedsLAPIKey, + )) case decisionsURL == "" && cfg.CrowdSecKey != "": e.check("SWWAF_CROWDSEC_LAPI_KEY", fmt.Errorf( - "is set while SWWAF_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused)) + "is set while SWWAF_CROWDSEC_LAPI_URL is unset; %w", + errLAPIKeyUnused, + )) case decisionsURL != "" && (slices.Contains(cfg.BlocklistURLs, decisionsURL) || decisionsURL == cfg.ASNLimitPercentURL): - e.check("SWWAF_CROWDSEC_LAPI_URL", fmt.Errorf("gives the decision list %q, which %w", - decisionsURL, errAnotherList)) + e.check( + "SWWAF_CROWDSEC_LAPI_URL", + fmt.Errorf("gives the decision list %q, which %w", + decisionsURL, errAnotherList), + ) } } @@ -1053,7 +1221,9 @@ func (e *environment) headerNames(name, defaultValue string) []string { // durationNotOff reads a setting that is a duration and, unlike a // timeout, cannot be off. -func (e *environment) durationNotOff(name, defaultValue string) time.Duration { +func (e *environment) durationNotOff( + name, defaultValue string, +) time.Duration { duration, err := ParseDurationNotOff(e.value(name, defaultValue)) e.check(name, err) @@ -1233,7 +1403,10 @@ func (e *environment) instanceName() string { value := e.value("SWWAF_INSTANCE_NAME", hostname) if !utf8.ValidString(value) { - e.check("SWWAF_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8)) + e.check( + "SWWAF_INSTANCE_NAME", + fmt.Errorf("%q %w", value, errNotUTF8), + ) } return value @@ -1242,7 +1415,10 @@ func (e *environment) instanceName() string { // appName reads the setting that is the APP-NAME of the records the log // lines are sent in, by default the instance name. Its value is checked // when it is set, and, while lines are sent, when it is the instance name. -func (e *environment) appName(name, instanceName string, sending bool) string { +func (e *environment) appName( + name, instanceName string, + sending bool, +) string { value, set := e.lookup(name) if !set { value = instanceName @@ -1255,8 +1431,14 @@ func (e *environment) appName(name, instanceName string, sending bool) string { case set: e.check(name, fmt.Errorf("%q %w", value, errNotAppName)) case sending: - e.check(name, fmt.Errorf("is unset, and SWWAF_INSTANCE_NAME %q, its default, %w", - value, errNotAppName)) + e.check( + name, + fmt.Errorf( + "is unset, and SWWAF_INSTANCE_NAME %q, its default, %w", + value, + errNotAppName, + ), + ) } return value @@ -1265,11 +1447,16 @@ func (e *environment) appName(name, instanceName string, sending bool) string { // checkInstanceNameForNtfy refuses an instance name that holds a control // character while ntfySet, SWWAF_ALERT_NTFY_URL being set: ntfy is sent // the instance name in a header, which cannot hold one. -func (e *environment) checkInstanceNameForNtfy(instanceName string, ntfySet bool) { +func (e *environment) checkInstanceNameForNtfy( + instanceName string, + ntfySet bool, +) { if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) { e.check("SWWAF_INSTANCE_NAME", fmt.Errorf( "%q %w, and is sent to ntfy in a header while SWWAF_ALERT_NTFY_URL is set", - instanceName, errControlCharacter)) + instanceName, + errControlCharacter, + )) } } @@ -1589,7 +1776,10 @@ func parseNamedNetblocks(value string) ([]anomaly.NamedNetblock, error) { return nil, fmt.Errorf("%q %w", name, errListedTwice) } - named = append(named, anomaly.NamedNetblock{Name: name, Netblock: netblock}) + named = append( + named, + anomaly.NamedNetblock{Name: name, Netblock: netblock}, + ) } return named, nil @@ -1828,8 +2018,11 @@ func parseHeaderNames(value string) ([]string, error) { header := strings.ToLower(item) switch header { case "host": - return nil, fmt.Errorf("%q %w; the request's host is the field host", - item, errHeaderTakenOut) + return nil, fmt.Errorf( + "%q %w; the request's host is the field host", + item, + errHeaderTakenOut, + ) case "transfer-encoding": return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut) } @@ -1866,9 +2059,11 @@ func parseUpstreamURL(value string) (*url.URL, error) { } onlySchemeAndHost := (upstream.Scheme == "http" || upstream.Scheme == "https") && - upstream.Hostname() != "" && upstream.User == nil && upstream.Opaque == "" && + upstream.Hostname() != "" && upstream.User == nil && + upstream.Opaque == "" && (upstream.Path == "" || upstream.Path == "/") && - upstream.RawQuery == "" && upstream.Fragment == "" + upstream.RawQuery == "" && + upstream.Fragment == "" if !onlySchemeAndHost { return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL) } @@ -1892,7 +2087,11 @@ func parseLogRemoteURL(value string) (*url.URL, error) { return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL) } - schemes := []string{remotelog.SchemeUDP, remotelog.SchemeTCP, remotelog.SchemeTLS} + schemes := []string{ + remotelog.SchemeUDP, + remotelog.SchemeTCP, + remotelog.SchemeTLS, + } port, err := strconv.ParseUint(remote.Port(), 10, 16) onlySchemeHostAndPort := slices.Contains(schemes, remote.Scheme) && @@ -1955,7 +2154,9 @@ func isHTTPURL(u *url.URL) bool { return (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" && (u.Port() == "" || (err == nil && port != 0)) && - u.User == nil && u.Opaque == "" && u.Fragment == "" + u.User == nil && + u.Opaque == "" && + u.Fragment == "" } // isListURL reports whether value is a URL a list can be fetched from, as @@ -2022,9 +2223,12 @@ func parseZones(value string) ([]string, error) { for i, zone := range zones { shown := MaskZoneKey(zone) - listedBefore := slices.ContainsFunc(zones[:i], func(earlier string) bool { - return strings.EqualFold(MaskZoneKey(earlier), shown) - }) + listedBefore := slices.ContainsFunc( + zones[:i], + func(earlier string) bool { + return strings.EqualFold(MaskZoneKey(earlier), shown) + }, + ) switch { case len(zone) > maxZoneLength: @@ -2111,8 +2315,13 @@ func parseWebhookHeaders(value string) (http.Header, string, error) { name, headerValue, found := strings.Cut(item, ":") name = strings.TrimSpace(name) - if !found || !IsHeaderName(name) || strings.ContainsAny(headerValue, "\r\n\x00") { - return nil, "", fmt.Errorf("item %d %w", i+1, errNotWebhookHeader) + if !found || !IsHeaderName(name) || + strings.ContainsAny(headerValue, "\r\n\x00") { + return nil, "", fmt.Errorf( + "item %d %w", + i+1, + errNotWebhookHeader, + ) } headers.Add(name, strings.TrimSpace(headerValue)) diff --git a/internal/lookup/file.go b/internal/lookup/file.go index d81bbeb..2152d12 100644 --- a/internal/lookup/file.go +++ b/internal/lookup/file.go @@ -16,6 +16,11 @@ import ( "sneak.berlin/go/smallwebwaf/internal/alerts" ) +// FIXME: you can't call things just "File" - that's super vague. + +// FIXME: "lookup database" - in memory on disk? you must not be so +// ambiguous when describing things. + // quietTime is how long the lookup database must go without a change // before it is read again, so that a file still being copied in is read // only once whole. @@ -133,8 +138,11 @@ func (f *File) Watch(ctx context.Context) { } if err != nil { - f.params.ProcessLog.Error("cannot watch the lookup database for replacements", - "error", err.Error()) + f.params.ProcessLog.Error( + "cannot watch the lookup database for replacements", + "error", + err.Error(), + ) return } @@ -194,7 +202,10 @@ func (f *File) readAgain() { f.params.Alerts.Raise(alerts.Alert{ Event: alerts.EventFileError, Reason: kept, - Detail: map[string]any{"file": f.params.Path, "error": err.Error()}, + Detail: map[string]any{ + "file": f.params.Path, + "error": err.Error(), + }, }) f.params.ProcessLog.Error(kept, "error", err.Error()) @@ -212,7 +223,11 @@ func (f *File) use(reader *maxminddb.Reader) { f.lastRead = f.params.Now() f.mu.Unlock() - f.params.ProcessLog.Info("read the lookup database", "file", f.params.Path) + f.params.ProcessLog.Info( + "read the lookup database", + "file", + f.params.Path, + ) } // read reads the lookup database at path. The whole file is read into @@ -221,12 +236,19 @@ func (f *File) use(reader *maxminddb.Reader) { func read(path string) (*maxminddb.Reader, error) { data, err := os.ReadFile(path) //nolint:gosec // the file the admin names if err != nil { - return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH cannot be read: %w", err) + return nil, fmt.Errorf( + "SWWAF_LOOKUP_DB_PATH cannot be read: %w", + err, + ) } reader, err := maxminddb.OpenBytes(data) if err != nil { - return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err) + return nil, fmt.Errorf( + "SWWAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", + path, + err, + ) } return reader, nil diff --git a/internal/requestlog/requestlog.go b/internal/requestlog/requestlog.go index c45e821..2f311f7 100644 --- a/internal/requestlog/requestlog.go +++ b/internal/requestlog/requestlog.go @@ -19,29 +19,51 @@ const ( // ActionForward is a request servewaf allowed, which rproxy passes to the // app. ActionForward = "forward" + + //FIXME: "too_large" doesn't tell a reader what that means. + //request_header_too_large does. you must not use ambiguous terms. + // ActionTooLarge is a request over the header limits. ActionTooLarge = "too_large" + // ActionRateLimited is a request refused because it took its client // over a rate limit, which bans the client. + + //FIXME: better would be "client_is_rate_limited" ActionRateLimited = "rate_limited" + // ActionBanned is a request refused because a ban covers its client, // or because it matched a ban rule, asked for a trap path or the // CrowdSec decision list lists its client, each of which bans the // client. + + // FIXME: if I saw this, i would assume they are permanently banned. is that + // true? if not, there should be TemporarilyBanned and + // PermanentlyBanned ActionBanned = "banned" + // ActionRuleBlocked is a request refused because it matched a block // rule. + + // FIXME: "blocked_by_block_rule" ActionRuleBlocked = "rule_blocked" + // ActionWAFBlocked is a request refused because the Core Rule Set // scored it at or over SWWAF_WAF_ANOMALY_THRESHOLD. ActionWAFBlocked = "waf_blocked" + // ActionDenied is a request refused because its client is in // SWWAF_DENY_NETS, in a blocklist while SWWAF_BLOCKLIST_ACTION is deny, // or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while // SWWAF_REPUTATION_ACTION is deny. ActionDenied = "denied" + // ActionCountryDenied is a request refused for its client's country. ActionCountryDenied = "country_denied" + + // FIXME: you have country_denied and denied. why isn't it + // client_denied? ALWAYS BE CONSISTENT AND UNAMBIGUOUS + // ActionAdmin is a request servewaf answered at one of its own endpoints, // or a question about a path under /_smallwebwaf/. ActionAdmin = "admin" @@ -245,7 +267,11 @@ func FormatTime(t time.Time) string { // Milliseconds is d in milliseconds, to the microsecond. func Milliseconds(d time.Duration) float64 { - return float64(d.Microseconds()) / float64(time.Millisecond/time.Microsecond) + return float64( + d.Microseconds(), + ) / float64( + time.Millisecond/time.Microsecond, + ) } // NewProcessLogger returns the logger for the process's own messages: @@ -253,17 +279,25 @@ func Milliseconds(d time.Duration) float64 { // as a request line's, and instanceName, SWWAF_INSTANCE_NAME, as instance. // It writes only the messages at level, SWWAF_LOG_LEVEL, or more severe; // the request lines Write writes are never held back. -func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger { +func NewProcessLogger( + w io.Writer, + instanceName string, + level slog.Level, +) *slog.Logger { handler := slog.NewJSONHandler(w, &slog.HandlerOptions{ Level: level, ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr { if attr.Key == slog.TimeKey && len(groups) == 0 { - return slog.String(slog.TimeKey, FormatTime(attr.Value.Time())) + return slog.String( + slog.TimeKey, + FormatTime(attr.Value.Time()), + ) } return attr }, }) - return slog.New(handler).With("type", "process", "instance", instanceName) + return slog.New(handler). + With("type", "process", "instance", instanceName) }