package proxy import ( "errors" "net/http" "os" "time" "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/config" "sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/waf" ) // checkCoreRuleSet inspects the request with the Core Rule Set, unless // SWWAF_WAF_MODE is off or SWWAF_WAF_EXEMPT_PATHS exempts its path, as // pathExempt decides, and notes the rules it matched and its score in the // log line, and the rules in the metrics. A score at or over // SWWAF_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert, // and in block mode refuses the request, which is an offence its client's // history counts, and so returns ActionWAFBlocked. It returns "" for a // request it does not refuse, and for one whose body meets a size or time // limit while the Core Rule Set reads it, which it notes nothing of. func (rq *request) checkCoreRuleSet() string { cfg := rq.h.config if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in.URL, cfg.WAFExemptPaths) { return "" } start := time.Now() result := rq.inspect() if rq.refused.Load() != nil { return "" // the refusal for that limit, which check returns } rq.line.DurationWAF = new(requestlog.Milliseconds(time.Since(start))) rq.line.WAFRuleIDs = result.RuleIDs rq.line.WAFScore = &result.Score for _, id := range result.RuleIDs { rq.h.metrics.WAFMatched(cfg.WAFMode, id) } threshold := cfg.WAFAnomalyThreshold if threshold == 0 || result.Score < threshold { return "" } rq.alertWAFBlock(result) if cfg.WAFMode == config.WAFModeDetect { return "" } rq.wafBlocked = true return requestlog.ActionWAFBlocked } // inspect runs the Core Rule Set on the request, which reads the part of // its body it inspects within SWWAF_CLIENT_REQUEST_TIMEOUT, and keeps that // part for the app. A client that runs out of time is refused with 408 // here, and a body over SWWAF_REQUEST_MAX_BYTES with 413 as it is read; // check returns the refusal. A body that breaks off for any other reason // is passed on as far as it came, and the request to the app fails there, // as it would have without the Core Rule Set. func (rq *request) inspect() waf.Result { if rq.body == nil { // Nothing is read of no body, so nothing can go wrong reading it. result, _, _ := rq.h.coreRuleSet.Inspect(rq.in, rq.client, http.NoBody) return result } _ = rq.rc.SetReadDeadline(rq.clientRequestDeadline()) result, read, err := rq.h.coreRuleSet.Inspect(rq.in, rq.client, rq.body) // The timeouts that run while the request goes to the app take over. _ = rq.rc.SetReadDeadline(time.Time{}) rq.body.readByCoreRuleSet = read if errors.Is(err, os.ErrDeadlineExceeded) { rq.refuse(refusal{ status: http.StatusRequestTimeout, action: requestlog.ActionTimedOut, limit: "SWWAF_CLIENT_REQUEST_TIMEOUT", }) } return result } // alertWAFBlock raises the waf_block alert for the request, which the Core // Rule Set scored at result, at or over SWWAF_WAF_ANOMALY_THRESHOLD. Its // detail gives the rule ids, the score, the method and the path with the // query, and, for a request that is not refused for it, the mode: detect, // or observe in observe mode. func (rq *request) alertWAFBlock(result waf.Result) { detail := map[string]any{ "rule_ids": result.RuleIDs, "score": result.Score, "method": rq.in.Method, "path": rq.in.URL.RequestURI(), } switch { case rq.h.config.WAFMode == config.WAFModeDetect: detail["mode"] = config.WAFModeDetect case rq.h.config.Observe: detail["mode"] = "observe" } rq.h.alerts.Raise(alerts.Alert{ Event: alerts.EventWAFBlock, Client: rq.client, Netblock: rq.h.clientGroup(rq.client), ASN: rq.line.ASN, ASName: rq.line.ASName, Country: rq.line.Country, Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD", Detail: detail, }) }