// Package proxy is servewaf's server. It runs the checks on the request each of // rproxy's questions carries, and answers rproxy what to do with that request. It // takes rproxy's report once the visitor's answer has ended, which counts the // request's bytes and writes its last log line. It answers servewaf's own // endpoints: the health check, the metrics, the bans and the clients. It passes // nothing to an app: internal/rproxy does that. package proxy import ( "io" "log/slog" "net/http" "time" lru "github.com/hashicorp/golang-lru/v2" "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/anomaly" "sneak.berlin/go/smallwebwaf/internal/bans" "sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/metrics" "sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/reputation" "sneak.berlin/go/smallwebwaf/internal/rules" "sneak.berlin/go/smallwebwaf/internal/waf" ) // adminPrefix starts the path of each of servewaf's own endpoints. The app never // sees a path under it. const adminPrefix = "/_smallwebwaf/" // HealthPath is the health endpoint of servewaf and of rproxy, which the // container's health check asks. const HealthPath = "/_smallwebwaf/healthz" // MetricsPath is where the metrics are, for a request that carries // SWWAF_METRICS_TOKEN. const MetricsPath = "/_smallwebwaf/metrics" // BansPath is where an admin lists and adds bans, and, followed by / and // a client's address, lifts them, with SWWAF_ADMIN_TOKEN. const BansPath = "/_smallwebwaf/bans" // ClientsPath is where an admin asks what servewaf knows of a client, by the // client's address after it, with SWWAF_ADMIN_TOKEN. const ClientsPath = "/_smallwebwaf/clients/" // Params are what NewServeWAF needs. type Params struct { Config *config.Config // RequestLog receives two JSON lines per question, one at its decision and // one when its report comes, and one per request to servewaf's own endpoints. RequestLog io.Writer // ProcessLog receives the process's own messages. ProcessLog *slog.Logger // GeoJSURL is where clients' AS numbers and countries are looked up // while SWWAF_LOOKUP_SOURCE is geojs, normally lookup.URL. GeoJSURL string // AbuseIPDBURL is where clients are checked with AbuseIPDB while // SWWAF_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL. AbuseIPDBURL string // LookupFile is the lookup database they are looked up in while // SWWAF_LOOKUP_SOURCE is file, and nil otherwise. LookupFile *lookup.File // Now tells the time by which requests are counted for the rate // limits, bans are made and run out, and GeoJS's answers are kept, // normally time.Now in UTC, the time the state files give. Now func() time.Time // Rules are the rule files' rules, which each request is checked // against. Rules *rules.Files // Alerts receive the alert for each ban servewaf makes or makes // permanent, for each count over an anomaly threshold, for each request // whose client a blocklist, the CrowdSec decision list, a DNSBL zone or // AbuseIPDB lists, for each request the Core Rule Set scores at or over // SWWAF_WAF_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list // failing, a query to a DNSBL zone or a check with AbuseIPDB failing, // or the day's AbuseIPDB checks used up. Alerts *alerts.Queue } // Server is the server servewaf runs, with the parts whose state the state // files keep, the lookup database, nil unless SWWAF_LOOKUP_SOURCE is file, the // lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts, // AbuseIPDB's scores and checks spent, and the metrics. type Server struct { *http.Server Ledger *bans.Ledger Limiter *ratelimit.Limiter GeoJS *lookup.GeoJS Anomalies *anomaly.Counters LookupFile *lookup.File Lists *reputation.Lists DNSBL *reputation.DNSBL AbuseIPDB *reputation.AbuseIPDB Metrics *metrics.Metrics } // newHandler returns the handler with what every request shares, as the // settings in params make it. func newHandler(params Params) *handler { m := metrics.New(params.Config.MetricsTopN, params.Config.InstanceName) lists, dnsbl, abuseIPDB := newReputation(params, m) awaitingReport, err := lru.New[string, *decision](maxAwaitingReport) if err != nil { panic(err) // New fails only for a size below one } h := &handler{ config: params.Config, requestLog: params.RequestLog, processLog: params.ProcessLog, now: params.Now, metrics: m, awaitingReport: awaitingReport, limiter: ratelimit.New(ratelimit.Limits{ PerMinute: params.Config.RateLimitPerMinute, PerHour: params.Config.RateLimitPerHour, PerDay: params.Config.RateLimitPerDay, BytesPerMinute: params.Config.BytesLimitPerMinute, BytesPerHour: params.Config.BytesLimitPerHour, BytesPerDay: params.Config.BytesLimitPerDay, }, params.Config.MaxTrackedClients), ledger: bans.New(bans.Rules{ LimitBanDuration: params.Config.LimitBanDuration, LimitBanRepeatWindow: params.Config.LimitBanRepeatWindow, MaxBanDuration: params.Config.MaxBanDuration, AttackBanDuration: params.Config.AttackBanDuration, MaxBans: params.Config.MaxBans, }), anomalies: anomaly.New(anomaly.Params{ Client: params.Config.AnomalyClient, Net: params.Config.AnomalyNet, ASN: params.Config.AnomalyASN, Total: params.Config.AnomalyTotal, Watch: params.Config.AnomalyWatch, NetV4Prefix: params.Config.AnomalyNetV4Prefix, NetV6Prefix: params.Config.AnomalyNetV6Prefix, NamedNetblocks: params.Config.WatchNets, Alerts: params.Alerts, }), lookupFile: params.LookupFile, lists: lists, dnsbl: dnsbl, abuseIPDB: abuseIPDB, rules: params.Rules, coreRuleSet: newCoreRuleSet(params.Config), alerts: params.Alerts, } h.geojs = lookup.New(lookup.Params{ URL: params.GeoJSURL, Timeout: params.Config.LookupTimeout, // The country lists, the headers and the biased thresholds act on // the answer before the request goes on. Wait: len(params.Config.DeniedCountries) > 0 || len(params.Config.ExclusivelyAllowedCountries) > 0 || params.Config.AddLookupHeaders || biasedThresholdsSet(params.Config), Answered: h.addLookup, Now: params.Now, ProcessLog: params.ProcessLog, Metrics: m, Alerts: params.Alerts, }) m.AddBansAndClients(h.ledger, h.limiter, params.Now) m.AddRules(params.Rules) return h } // newReputation returns the lists fetched from URLs, the DNSBL zones' // verdicts and AbuseIPDB's scores, as the settings in params name them, // with none fetched, asked for or checked yet, and adds their metrics to // m, AbuseIPDB's while SWWAF_ABUSEIPDB_KEY is set. func newReputation( params Params, m *metrics.Metrics, ) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) { cfg := params.Config lists := reputation.New(reputation.Params{ BlocklistURLs: cfg.BlocklistURLs, Refresh: cfg.BlocklistRefresh, ASNLimitPercentURL: cfg.ASNLimitPercentURL, CrowdSecDecisionsURL: cfg.CrowdSecDecisionsURL, CrowdSecKey: cfg.CrowdSecKey, Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts, }) dnsbl := reputation.NewDNSBL(reputation.DNSBLParams{ Zones: cfg.DNSBLZones, Resolver: cfg.DNSBLResolver, CacheTTL: cfg.ReputationCacheTTL, Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts, }) abuseIPDB := reputation.NewAbuseIPDB(reputation.AbuseIPDBParams{ URL: params.AbuseIPDBURL, Key: cfg.AbuseIPDBKey, MinScore: cfg.AbuseIPDBMinScore, DailyBudget: cfg.AbuseIPDBDailyBudget, CacheTTL: cfg.ReputationCacheTTL, Timeout: cfg.ReputationTimeout, Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts, }) m.AddReputation(lists, dnsbl) if cfg.AbuseIPDBKey != "" { m.AddAbuseIPDB(abuseIPDB) } return lists, dnsbl, abuseIPDB } // newCoreRuleSet returns the Core Rule Set at SWWAF_WAF_PARANOIA_LEVEL, // without the rules SWWAF_WAF_DISABLED_RULES switches off, reading bodies // up to SWWAF_WAF_BODY_LIMIT, or nil while SWWAF_WAF_MODE is off. It reads at // most QuestionBodyMaxBytes less one byte: a question's body of // QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a // JSON or XML body over its limit. func newCoreRuleSet(cfg *config.Config) *waf.CoreRuleSet { if cfg.WAFMode == config.WAFModeOff { return nil } coreRuleSet, err := waf.New(waf.Params{ ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules, BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1), }) if err != nil { // The Core Rule Set is built in, and the settings cannot break it: // the paranoia level is from 1 to 4, and the id of no rule switches // nothing off. panic(err) } return coreRuleSet } // handler is servewaf's handler. It holds what every request shares; what // belongs to one request is in a request. type handler struct { config *config.Config requestLog io.Writer processLog *slog.Logger now func() time.Time metrics *metrics.Metrics limiter *ratelimit.Limiter ledger *bans.Ledger geojs *lookup.GeoJS anomalies *anomaly.Counters lookupFile *lookup.File lists *reputation.Lists dnsbl *reputation.DNSBL abuseIPDB *reputation.AbuseIPDB rules *rules.Files coreRuleSet *waf.CoreRuleSet alerts *alerts.Queue // awaitingReport holds the decision on each answered question by its id // until its report comes, and nil for a question still being handled. awaitingReport *lru.Cache[string, *decision] }