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
123 lines
4.4 KiB
Go
123 lines
4.4 KiB
Go
package proxy
|
|
|
|
import (
|
|
"context"
|
|
"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"
|
|
)
|
|
|
|
// deny is the SWWAF_BLOCKLIST_ACTION and the SWWAF_REPUTATION_ACTION that
|
|
// refuses the requests of a client a source lists.
|
|
const deny = "deny"
|
|
|
|
// blocklistDenied notes the blocklists that list the client, as
|
|
// noteListed does, and reports whether SWWAF_BLOCKLIST_ACTION, being deny,
|
|
// refuses the request. Being limit, it lowers the client's limits instead
|
|
// (see limitPercentages), and being log, it does nothing more.
|
|
func (rq *request) blocklistDenied() bool {
|
|
listedBy := rq.h.lists.ListedBy(rq.client)
|
|
rq.blocklisted = len(listedBy) > 0
|
|
rq.noteListed(listedBy, "listed by a blocklist")
|
|
|
|
return rq.blocklisted && rq.h.config.BlocklistAction == deny
|
|
}
|
|
|
|
// crowdSecBanned reports whether a decision of the CrowdSec decision list
|
|
// on the client is in force at now. If one is, it notes the list, as
|
|
// noteHit does, and bans the client until that decision ends.
|
|
func (rq *request) crowdSecBanned(now time.Time) bool {
|
|
decision, listed := rq.h.lists.CrowdSecDecision(rq.client, now)
|
|
if !listed {
|
|
return false
|
|
}
|
|
|
|
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
|
|
"listed by the CrowdSec decision list")
|
|
rq.banForCrowdSec(now, decision)
|
|
|
|
return true
|
|
}
|
|
|
|
// dnsblDenied notes the DNSBL zones whose verdict lists the client, as
|
|
// noteListed does, and reports whether SWWAF_REPUTATION_ACTION, being
|
|
// deny, refuses the request. Being limit, it lowers the client's limits
|
|
// instead (see limitPercentages), and being log, it does nothing more. A
|
|
// zone without a verdict on the client is asked about it in the
|
|
// background, and the request does not wait for the answer. ctx is the
|
|
// request's own context.
|
|
func (rq *request) dnsblDenied(ctx context.Context) bool {
|
|
listedBy := rq.h.dnsbl.ListedBy(ctx, rq.client)
|
|
rq.dnsblListed = len(listedBy) > 0
|
|
rq.noteListed(listedBy, "listed by a DNSBL zone")
|
|
|
|
return rq.dnsblListed && rq.h.config.ReputationAction == deny
|
|
}
|
|
|
|
// abuseIPDBDenied notes AbuseIPDB, as noteHit does, with the score, when
|
|
// its score of the client is a hit, and reports whether
|
|
// SWWAF_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied
|
|
// does for a zone. While SWWAF_ABUSEIPDB_KEY is unset it does nothing. A
|
|
// client without a score is checked in the background, by the request's
|
|
// address, if its history counts an offence, and the request does not
|
|
// wait for the answer. The score is then used for each address of the
|
|
// client. ctx is the request's own context.
|
|
func (rq *request) abuseIPDBDenied(ctx context.Context) bool {
|
|
if rq.h.config.AbuseIPDBKey == "" {
|
|
return false
|
|
}
|
|
|
|
client := rq.h.clientGroup(rq.client)
|
|
held, _ := rq.h.limiter.Client(client)
|
|
offender := held.History.Offences != ratelimit.Offences{}
|
|
|
|
score, hit := rq.h.abuseIPDB.Hit(ctx, client, rq.client, offender)
|
|
if !hit {
|
|
return false
|
|
}
|
|
|
|
rq.abuseIPDBHit = true
|
|
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
|
"scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE")
|
|
|
|
return rq.h.config.ReputationAction == deny
|
|
}
|
|
|
|
// noteListed notes each of sources, the URLs of the blocklists or the
|
|
// DNSBL zones, their keys masked, that list the client, as noteHit does,
|
|
// with reason.
|
|
func (rq *request) noteListed(sources []string, reason string) {
|
|
for _, source := range sources {
|
|
rq.noteHit(bans.ReputationHit{Source: source}, reason)
|
|
}
|
|
}
|
|
|
|
// noteHit adds hit's source, which lists the client, to the log line's
|
|
// reputation, and hit to the notes of a ban the request makes, counts the
|
|
// source in the metrics, and raises a reputation_hit alert with reason,
|
|
// whose detail gives hit's source and score.
|
|
func (rq *request) noteHit(hit bans.ReputationHit, reason string) {
|
|
detail := map[string]any{"source": hit.Source}
|
|
if hit.Score != nil {
|
|
detail["score"] = *hit.Score
|
|
}
|
|
|
|
rq.line.Reputation = append(rq.line.Reputation, hit.Source)
|
|
rq.reputation = append(rq.reputation, hit)
|
|
rq.h.metrics.ReputationHit(hit.Source)
|
|
rq.h.alerts.Raise(alerts.Alert{
|
|
Event: alerts.EventReputationHit,
|
|
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: reason,
|
|
Detail: detail,
|
|
})
|
|
}
|