check / check (push) Waiting to run
SWWAF_ANOMALY_CLIENT_*, _NET_*, _ASN_*, _TOTAL_* and SWWAF_WATCH_* with SWWAF_WATCH_NETS: requests and bytes per minute and per hour, each off by default; with all off, nothing is counted. Otherwise every request but the health check is counted, allow-listed and exempt ones included; a count over its threshold raises an anomaly alert, with a cooldown per scope. At most 20,000 counters, kept in alerts.json. A per-AS-number threshold with lookups off, or a malformed SWWAF_WATCH_NETS, stops the start. A cooldown that has run out is dropped as the hour ends, whatever it held back; the hour's summary gives its repeats. Judgement call: refused requests are counted too. Judgement call: per-client counters are kept in alerts.json, which SPEC.md does not list. Judgement call: a request counts for an AS number only if the lookup answered before it ended. Model: opus-5-5
291 lines
8.6 KiB
Go
291 lines
8.6 KiB
Go
package proxy
|
|
|
|
import (
|
|
"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/requestlog"
|
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
|
)
|
|
|
|
// 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(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 response has ended, and notes the client's
|
|
// byte totals for the log line; its requests stay there as the rate limits
|
|
// counted them. Only a request passed to the app 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(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)
|
|
}
|
|
}
|
|
|
|
// 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. For an
|
|
// upgraded connection, such as a WebSocket, which has closed by then, what
|
|
// it carried from the app counts with the response's and what it carried
|
|
// from the client with the request's.
|
|
func (rq *request) countedBytes() int64 {
|
|
response, request := rq.out.bytes, rq.requestBytes()
|
|
if rq.upgraded != nil {
|
|
response += rq.upgraded.fromApp.Load()
|
|
request += rq.upgraded.toApp.Load()
|
|
}
|
|
|
|
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, and notes the offence for the log line. status is what
|
|
// the client was sent, or is sent: SWWAF_BAN_RESPONSE for a request over
|
|
// a rate limit, the app's answer for one whose bytes broke a byte limit.
|
|
// The ban's notes give the client's limit percentage for that kind of
|
|
// limit. 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) {
|
|
rq.line.LimitHit = hit.Window
|
|
if hit.Kind == ratelimit.KindBytes {
|
|
rq.line.LimitHit += "_bytes" // as counts names the byte totals
|
|
}
|
|
|
|
rq.line.Offence = requestlog.OffenceLimit
|
|
|
|
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,
|
|
Request: rq.noted(now, status),
|
|
Requests: rq.netblockRequests(netblock),
|
|
}
|
|
|
|
percent := rq.limitPercent
|
|
if hit.Kind == ratelimit.KindBytes {
|
|
percent = rq.bytesPercent
|
|
}
|
|
|
|
notes.LimitPercent, notes.LimitPercentSetting = percent.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(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, the match of rule, a ban rule. 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, rule rules.Rule) {
|
|
netblock := rq.h.netblock(rq.client)
|
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
|
|
return
|
|
}
|
|
|
|
notes := bans.Notes{
|
|
ASN: rq.line.ASN,
|
|
ASName: rq.line.ASName,
|
|
Country: rq.line.Country,
|
|
RuleID: rule.ID,
|
|
Target: rule.Target,
|
|
Request: rq.noted(now, rq.h.config.BanResponse),
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
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.in.Method,
|
|
Host: rq.in.Host,
|
|
Path: rq.in.URL.RequestURI(),
|
|
Status: status,
|
|
UserAgent: rq.in.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 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)
|
|
}
|