check / check (push) Waiting to run
The package passes nothing to an app since rproxy took that over, so it is named after the command it serves. Its constructor is New, like rproxy's. The rproxy tests call their stand-in for servewaf standIn, since a local servewaf would hide the package. No change of behaviour. Model: opus-5-5
366 lines
11 KiB
Go
366 lines
11 KiB
Go
package servewaf
|
|
|
|
import (
|
|
"net/http"
|
|
"net/netip"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
)
|
|
|
|
// banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged
|
|
// with action.
|
|
func (rq *request) banResponse(action string) *refusal {
|
|
return &refusal{status: rq.h.config.BanResponse, action: action}
|
|
}
|
|
|
|
// banned reports whether a ban on a netblock the client is in covers the
|
|
// request at now, and notes for the log line when that ban ends. A
|
|
// request that makes the ban permanent, or in observe mode would have,
|
|
// raises the alert for it.
|
|
func (rq *request) banned(now time.Time) bool {
|
|
check := rq.h.ledger.Check
|
|
if rq.h.config.Observe {
|
|
check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is
|
|
}
|
|
|
|
ban, banned, madePermanent := check(rq.client, now)
|
|
if banned {
|
|
rq.line.BanExpires = banExpires(ban)
|
|
}
|
|
|
|
if madePermanent {
|
|
ban.Expires = time.Time{} // the ban made permanent, which Find leaves as it is
|
|
rq.alertBan(ban)
|
|
}
|
|
|
|
return banned
|
|
}
|
|
|
|
// limitBroken counts the request for the rate limits at now, notes the
|
|
// client's counts for the log line, and reports whether the request takes
|
|
// the client over a rate limit, as its limit percentage lowers it, which
|
|
// breaks it.
|
|
func (rq *request) limitBroken(now time.Time) bool {
|
|
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
|
|
rq.limitPercent.percent)
|
|
rq.line.Counts = counts
|
|
|
|
if over {
|
|
rq.banForLimit(now, hit, rq.h.config.BanResponse)
|
|
}
|
|
|
|
return over
|
|
}
|
|
|
|
// countBytes counts the request's bytes, as countedBytes gives them, for
|
|
// the byte limits, once its report has come, and notes the client's byte totals
|
|
// for the log line; its requests stay there as the rate limits counted them.
|
|
// Only an allowed request has them counted, and only one the rate limits
|
|
// counted; in observe mode, not one that enforce mode would have refused. Bytes
|
|
// that take the client over a byte limit, as its limit percentage for the byte
|
|
// limits lowers it, break it; the response was passed on whole.
|
|
func (rq *request) countBytes() {
|
|
if !rq.counted || rq.line.WouldAction != "" {
|
|
return
|
|
}
|
|
|
|
now := rq.h.now()
|
|
|
|
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
|
|
rq.countedBytes(), rq.bytesPercent.percent)
|
|
rq.line.Counts.MinuteBytes = counts.MinuteBytes
|
|
rq.line.Counts.HourBytes = counts.HourBytes
|
|
rq.line.Counts.DayBytes = counts.DayBytes
|
|
|
|
if over {
|
|
rq.banForLimit(now, hit, rq.out.status)
|
|
}
|
|
}
|
|
|
|
// countRefusal counts the request for the error burst once it has been
|
|
// answered, if servewaf refused it after a rule file match, a trap path or a
|
|
// Core Rule Set match, and in observe mode if enforce mode would have: more than
|
|
// SWWAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute
|
|
// break a limit. A client in SWWAF_ALLOW_NETS, which the checks skip, is
|
|
// not counted, and nothing is while the threshold is off.
|
|
func (rq *request) countRefusal() {
|
|
cfg := rq.h.config
|
|
if cfg.ErrorBurstThreshold == 0 {
|
|
return
|
|
}
|
|
|
|
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
|
|
return
|
|
}
|
|
|
|
now := rq.h.now()
|
|
|
|
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
|
|
cfg.ErrorBurstThreshold)
|
|
if !over {
|
|
return
|
|
}
|
|
|
|
// What the client was sent, or in observe mode would have been.
|
|
status := rq.out.status
|
|
|
|
switch rq.line.WouldAction {
|
|
case requestlog.ActionRuleBlocked, requestlog.ActionWAFBlocked:
|
|
status = http.StatusForbidden
|
|
case requestlog.ActionBanned:
|
|
status = cfg.BanResponse
|
|
}
|
|
|
|
rq.banForLimit(now, hit, status)
|
|
}
|
|
|
|
// countedBytes returns the request's bytes, once it has ended, as the
|
|
// byte limits and the anomaly thresholds count them: the response's body
|
|
// bytes, the request's, or both, as SWWAF_BYTES_COUNT says.
|
|
func (rq *request) countedBytes() int64 {
|
|
response, request := rq.out.bytes, rq.requestBytes
|
|
|
|
switch rq.h.config.BytesCount {
|
|
case "response":
|
|
return response
|
|
case "request":
|
|
return request
|
|
default: // both
|
|
return response + request
|
|
}
|
|
}
|
|
|
|
// banForLimit bans the client's netblock at now for a broken limit, the
|
|
// one hit names, notes the offence for the log line and counts the hit in
|
|
// the metrics. status is what the client was sent, or is sent:
|
|
// SWWAF_BAN_RESPONSE for a request over a rate limit, what the report says for
|
|
// one whose bytes broke a byte limit, the refusal for one that broke the
|
|
// error burst. The ban's notes give the client's limit percentage for a
|
|
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
|
// the client's counters back to zero. In observe mode it makes no ban and
|
|
// sets nothing back, and raises the alert for the ban it would have made,
|
|
// if that alert would be sent.
|
|
func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
|
switch hit.Kind {
|
|
case ratelimit.KindBytes:
|
|
rq.line.LimitHit = hit.Window + "_bytes" // as counts names the byte totals
|
|
case ratelimit.KindRefusals:
|
|
rq.line.LimitHit = requestlog.LimitHitErrorBurst
|
|
default:
|
|
rq.line.LimitHit = hit.Window
|
|
}
|
|
|
|
rq.line.Offence = requestlog.OffenceLimit
|
|
rq.h.metrics.LimitHit(hit)
|
|
|
|
netblock := rq.h.netblock(rq.client)
|
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseLimit) {
|
|
return
|
|
}
|
|
|
|
notes := bans.Notes{
|
|
ASN: rq.line.ASN,
|
|
ASName: rq.line.ASName,
|
|
Country: rq.line.Country,
|
|
Kind: hit.Kind,
|
|
Limit: hit.Limit,
|
|
Window: hit.Window,
|
|
Count: hit.Count,
|
|
Reputation: rq.reputation,
|
|
Request: rq.noted(now, status),
|
|
Requests: rq.netblockRequests(netblock),
|
|
}
|
|
|
|
switch hit.Kind {
|
|
case ratelimit.KindRequests:
|
|
notes.LimitPercent, notes.LimitPercentSetting = rq.limitPercent.logged()
|
|
case ratelimit.KindBytes:
|
|
notes.LimitPercent, notes.LimitPercentSetting = rq.bytesPercent.logged()
|
|
}
|
|
|
|
if rq.h.config.Observe {
|
|
ban, wouldBan := rq.h.ledger.WouldBanForLimit(netblock, now, notes)
|
|
if wouldBan {
|
|
rq.alertBan(ban)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
ban, made := rq.h.ledger.BanForLimit(netblock, now, notes)
|
|
rq.h.limiter.Reset(rq.h.clientGroup(rq.client))
|
|
rq.line.BanExpires = banExpires(ban)
|
|
|
|
if made {
|
|
rq.alertBan(ban)
|
|
}
|
|
}
|
|
|
|
// banForAttack bans the client's netblock at now for a clear sign of
|
|
// attack, which notes name: the ban rule that matched, or the trap path
|
|
// asked for. It fills in the rest of the notes. In observe mode it makes
|
|
// no ban, and raises the alert for the ban it would have made, if that
|
|
// alert would be sent.
|
|
func (rq *request) banForAttack(now time.Time, notes bans.Notes) {
|
|
netblock := rq.h.netblock(rq.client)
|
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
|
|
return
|
|
}
|
|
|
|
notes.ASN = rq.line.ASN
|
|
notes.ASName = rq.line.ASName
|
|
notes.Country = rq.line.Country
|
|
notes.Reputation = rq.reputation
|
|
notes.Request = rq.noted(now, rq.h.config.BanResponse)
|
|
notes.Requests = rq.netblockRequests(netblock)
|
|
|
|
if rq.h.config.Observe {
|
|
ban, wouldBan := rq.h.ledger.WouldBanForAttack(netblock, now, notes)
|
|
if wouldBan {
|
|
rq.alertBan(ban)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
ban, made := rq.h.ledger.BanForAttack(netblock, now, notes)
|
|
rq.line.BanExpires = banExpires(ban)
|
|
|
|
if made {
|
|
rq.alertBan(ban)
|
|
}
|
|
}
|
|
|
|
// banForCrowdSec bans the client's netblock at now until decision,
|
|
// CrowdSec's decision on the client, ends. In observe mode it makes no
|
|
// ban, and raises the alert for the ban it would have made, if that alert
|
|
// would be sent.
|
|
func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
|
|
netblock := rq.h.netblock(rq.client)
|
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) {
|
|
return
|
|
}
|
|
|
|
notes := bans.Notes{
|
|
ASN: rq.line.ASN,
|
|
ASName: rq.line.ASName,
|
|
Country: rq.line.Country,
|
|
Reputation: rq.reputation,
|
|
Request: rq.noted(now, rq.h.config.BanResponse),
|
|
Requests: rq.netblockRequests(netblock),
|
|
}
|
|
|
|
if rq.h.config.Observe {
|
|
ban, wouldBan := rq.h.ledger.WouldBanForCrowdSec(netblock, now, decision.Expires,
|
|
decision.Scenario, notes)
|
|
if wouldBan {
|
|
rq.alertBan(ban)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
ban, made := rq.h.ledger.BanForCrowdSec(netblock, now, decision.Expires,
|
|
decision.Scenario, notes)
|
|
rq.line.BanExpires = banExpires(ban)
|
|
|
|
if made {
|
|
rq.alertBan(ban)
|
|
}
|
|
}
|
|
|
|
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
|
// made at now would be sent. In observe mode the ban the request would
|
|
// have made is worked out only then, at most once per
|
|
// SWWAF_ALERT_COOLDOWN and never with no webhook set: its notes count the
|
|
// netblock's requests, which can mean going through every client.
|
|
func (rq *request) wouldAlertBan(
|
|
netblock netip.Prefix, now time.Time, cause string,
|
|
) bool {
|
|
event := alerts.EventBan
|
|
if rq.h.ledger.WouldBePermanent(netblock, now, cause) {
|
|
event = alerts.EventPermanentBan
|
|
}
|
|
|
|
return rq.h.alerts.WouldSend(event, netblock)
|
|
}
|
|
|
|
// alertBan raises the alert for ban, which the request made, or made
|
|
// permanent: permanent_ban for a permanent ban, ban for another. Its
|
|
// detail gives the ban's cause, when it ends, and its notes, and in
|
|
// observe mode, where ban is the ban that would have been made, or made
|
|
// permanent, mode, observe.
|
|
func (rq *request) alertBan(ban bans.Ban) {
|
|
event := alerts.EventBan
|
|
if ban.Permanent() {
|
|
event = alerts.EventPermanentBan
|
|
}
|
|
|
|
detail := map[string]any{
|
|
"cause": ban.Cause, "ban_expires": banExpires(ban), "notes": ban.Notes,
|
|
}
|
|
if rq.h.config.Observe {
|
|
detail["mode"] = "observe"
|
|
}
|
|
|
|
rq.h.alerts.Raise(alerts.Alert{
|
|
Event: event,
|
|
Host: rq.site,
|
|
Client: rq.client,
|
|
Netblock: ban.Netblock,
|
|
ASN: ban.Notes.ASN,
|
|
ASName: ban.Notes.ASName,
|
|
Country: ban.Notes.Country,
|
|
Reason: ban.Reason,
|
|
Detail: detail,
|
|
})
|
|
}
|
|
|
|
// noted is the request, at now, with status, what the client was sent, or
|
|
// in observe mode would have been, as the notes of the ban it makes keep
|
|
// it.
|
|
func (rq *request) noted(now time.Time, status int) bans.Request {
|
|
return bans.Request{
|
|
Time: now,
|
|
Method: rq.line.Method,
|
|
Host: rq.line.Host,
|
|
Path: rq.requestURI,
|
|
Status: status,
|
|
UserAgent: rq.line.UserAgent,
|
|
}
|
|
}
|
|
|
|
// netblockRequests is how many requests netblock has sent since it was
|
|
// first seen, this one included: the histories count it only once it has
|
|
// ended.
|
|
func (rq *request) netblockRequests(netblock netip.Prefix) int64 {
|
|
return rq.h.limiter.Requests(netblock) + 1
|
|
}
|
|
|
|
// netblock is the netblock a ban on client covers: its IPv4 address,
|
|
// widened to SWWAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
|
// counts it in.
|
|
func (h *handler) netblock(client netip.Addr) netip.Prefix {
|
|
addr := client.Unmap()
|
|
if addr.Is4() {
|
|
return netip.PrefixFrom(addr, h.config.BanScopeV4Prefix).Masked()
|
|
}
|
|
|
|
return h.clientGroup(addr)
|
|
}
|
|
|
|
// banExpires is when ban ends, as the log line gives it: a time, or
|
|
// permanent.
|
|
func banExpires(ban bans.Ban) string {
|
|
if ban.Permanent() {
|
|
return permanent
|
|
}
|
|
|
|
return requestlog.FormatTime(ban.Expires)
|
|
}
|