check / check (push) Waiting to run
Coraza v3.8.1 runs the Core Rule Set 4.25.0 (coraza-coreruleset v4.25.0) after the rule files, with the six changes and the default SWWAF_WAF_DISABLED_RULES that SPEC.md gives; no body, no response. SWWAF_WAF_MODE, SWWAF_WAF_PARANOIA_LEVEL, SWWAF_WAF_ANOMALY_THRESHOLD and SWWAF_WAF_EXEMPT_PATHS as specified. In block mode a match is refused with 403, an offence counted toward the error burst; in detect mode it is let through. Both log waf_rule_ids, waf_score and duration_waf, raise waf_block, and count smallwebwaf_waf_matches_total. Judgement call: waf_block is raised in block mode too. Deviation: no engine-error path; with no body read, Coraza cannot fail. Model: opus-5-5
378 lines
11 KiB
Go
378 lines
11 KiB
Go
package proxy
|
|
|
|
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 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(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 smallwebwaf refused it after a rule file match, a trap path
|
|
// or a Core Rule Set match, or for a missing or wrong token, 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
|
|
}
|
|
|
|
// In observe mode, a request that enforce mode would have refused
|
|
// before it reached the endpoint has had no token refused there.
|
|
tokenRefused := rq.tokenRefused && rq.line.WouldAction == "" &&
|
|
!isInside(rq.client, cfg.AllowNets)
|
|
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked && !tokenRefused {
|
|
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. 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, 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, the app's answer 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,
|
|
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 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)
|
|
}
|