check / check (push) Successful in 4m27s
`smallwebwaf servewaf` runs today's checks on the visitor's request a question carries and answers allow, deny or close, instead of passing it to an app. rproxy's report then counts the bytes toward the byte limits and adds the request to the client's history. SPEC.md gives the exact question, answer and report, which rproxy follows. `smallwebwaf` with no command is unchanged. Judgement call: a third disposition, close, since closing the question's connection would read as servewaf down and let the request through. Judgement call: allow can answer a new request id, since a visitor behind a trusted proxy can choose its own. Model: opus-5-5
126 lines
3.8 KiB
Go
126 lines
3.8 KiB
Go
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, 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,
|
|
Host: rq.site,
|
|
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,
|
|
})
|
|
}
|