DNS blocklists asked in the background, verdicts kept (closes #104)
check / check (push) Waiting to run
check / check (push) Waiting to run
Zones in SWWAF_DNSBL_ZONES are asked about each client (RFC 5782 names) in the background, through the host's resolver or SWWAF_DNSBL_RESOLVER; no request waits. Verdicts last SWWAF_REPUTATION_CACHE_TTL and are kept in reputation.json, at most 100,000. After the blocklists, SWWAF_REPUTATION_ACTION (limit:25) denies, limits or logs a listed client; the log line names the zones, each raises reputation_hit, with metrics by zone. A failed, timed-out or refused query gives no verdict, raises source_failure, and pauses the zone a minute. Judgement call: answers in 127.255.255.0/24 or outside 127.0.0.0/8 are failures. Judgement call: the minute's pause after a failure; at most 1,000 queries at once. Rule suppressed: paralleltest on the DNSBL tests (Go's resolver shares state across synctest bubbles), funlen on the test of every logged setting. Model: opus-5-5
This commit is contained in:
@@ -61,6 +61,11 @@ const (
|
||||
blocklistURLs = "SWWAF_BLOCKLIST_URLS"
|
||||
blocklistRefresh = "SWWAF_BLOCKLIST_REFRESH"
|
||||
blocklistAction = "SWWAF_BLOCKLIST_ACTION"
|
||||
dnsblZones = "SWWAF_DNSBL_ZONES"
|
||||
dnsblResolver = "SWWAF_DNSBL_RESOLVER"
|
||||
reputationAction = "SWWAF_REPUTATION_ACTION"
|
||||
reputationCacheTTL = "SWWAF_REPUTATION_CACHE_TTL"
|
||||
reputationTimeout = "SWWAF_REPUTATION_TIMEOUT"
|
||||
banResponse = "SWWAF_BAN_RESPONSE"
|
||||
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
||||
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
||||
@@ -152,6 +157,9 @@ const (
|
||||
defaultAlertCooldown = "15m"
|
||||
)
|
||||
|
||||
// defaultReputationCacheTTL is the default of SWWAF_REPUTATION_CACHE_TTL.
|
||||
const defaultReputationCacheTTL = "24h"
|
||||
|
||||
// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS.
|
||||
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
|
||||
"content-type,origin,range"
|
||||
@@ -1322,6 +1330,130 @@ func TestASNLimitPercentURLThatIsABlocklistStopsTheStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// dronebl is a DNSBL zone, and spamhaus one of Spamhaus's, a name
|
||||
// containing the key of its keyed query service.
|
||||
const (
|
||||
dronebl = "dnsbl.dronebl.org"
|
||||
spamhaus = "abcdefghijklmnopqrstuvwxyz.xbl.dq.spamhaus.net"
|
||||
)
|
||||
|
||||
func TestDNSBLSettingsAsSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{})
|
||||
if len(cfg.DNSBLZones) != 0 || cfg.DNSBLResolver.IsValid() ||
|
||||
cfg.ReputationAction != actionLimit || cfg.ReputationLimitPercent != 25 ||
|
||||
cfg.ReputationCacheTTL != 24*time.Hour || cfg.ReputationTimeout != 2*time.Second {
|
||||
t.Errorf("by default, the zones %v, the resolver %s, the action %s:%d, the TTL %s "+
|
||||
"and the timeout %s, want no zone, no resolver, limit:25, 24h and 2s",
|
||||
cfg.DNSBLZones,
|
||||
cfg.DNSBLResolver, cfg.ReputationAction, cfg.ReputationLimitPercent,
|
||||
cfg.ReputationCacheTTL, cfg.ReputationTimeout)
|
||||
}
|
||||
|
||||
// The longest zone allowed, of 189 characters, with labels of 63, the
|
||||
// longest allowed.
|
||||
longest := strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." +
|
||||
strings.Repeat("c", 61)
|
||||
|
||||
for _, tc := range []struct {
|
||||
zones, resolver, action string
|
||||
// want are the zones, resolver, action and percent Config gives.
|
||||
want []string
|
||||
wantResolver string
|
||||
wantAction string
|
||||
wantPercent int64
|
||||
}{
|
||||
{
|
||||
dronebl + ", " + spamhaus, "192.0.2.53", actionDeny,
|
||||
[]string{dronebl, spamhaus}, "192.0.2.53:53", actionDeny, 0,
|
||||
},
|
||||
{
|
||||
longest, "192.0.2.53:5353", actionLog,
|
||||
[]string{longest}, "192.0.2.53:5353", actionLog, 0,
|
||||
},
|
||||
{
|
||||
"Dnsbl-1.Example", "2001:db8::53", "limit:10",
|
||||
[]string{"Dnsbl-1.Example"}, "[2001:db8::53]:53", actionLimit, 10,
|
||||
},
|
||||
{
|
||||
dronebl, "[2001:db8::53]:5353", "limit:0",
|
||||
[]string{dronebl}, "[2001:db8::53]:5353", actionLimit, 0,
|
||||
},
|
||||
} {
|
||||
cfg := fromEnvironment(t, environment{
|
||||
dnsblZones: tc.zones, dnsblResolver: tc.resolver, reputationAction: tc.action,
|
||||
reputationCacheTTL: "12h", reputationTimeout: "3s",
|
||||
})
|
||||
|
||||
if !slices.Equal(cfg.DNSBLZones, tc.want) ||
|
||||
cfg.DNSBLResolver.String() != tc.wantResolver ||
|
||||
cfg.ReputationAction != tc.wantAction ||
|
||||
cfg.ReputationLimitPercent != tc.wantPercent ||
|
||||
cfg.ReputationCacheTTL != 12*time.Hour ||
|
||||
cfg.ReputationTimeout != 3*time.Second {
|
||||
t.Errorf("%s=%s, %s=%s and %s=%s gave %v, %s, %s:%d, %s and %s", dnsblZones,
|
||||
tc.zones, dnsblResolver, tc.resolver, reputationAction, tc.action,
|
||||
cfg.DNSBLZones, cfg.DNSBLResolver, cfg.ReputationAction,
|
||||
cfg.ReputationLimitPercent, cfg.ReputationCacheTTL, cfg.ReputationTimeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidDNSBLSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
notZone = " is not a DNS zone such as dnsbl.dronebl.org"
|
||||
notResolver = " is not an IP address with an optional port, such as 192.0.2.53 " +
|
||||
"or [2001:db8::53]:5353"
|
||||
notAboveZero = " is not a duration above zero, such as 1h or 7d"
|
||||
)
|
||||
|
||||
label64 := strings.Repeat("a", 64) + ".example"
|
||||
tooLong := strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." +
|
||||
strings.Repeat("c", 62)
|
||||
|
||||
for _, tc := range []struct{ name, value, want string }{
|
||||
{dnsblZones, "dnsbl..example", `"dnsbl..example"` + notZone},
|
||||
{dnsblZones, "dnsbl.example.", `"dnsbl.example."` + notZone},
|
||||
{dnsblZones, "-dnsbl.example", `"-dnsbl.example"` + notZone},
|
||||
{dnsblZones, "dnsbl-.example", `"dnsbl-.example"` + notZone},
|
||||
{dnsblZones, "dns_bl.example", `"dns_bl.example"` + notZone},
|
||||
{dnsblZones, label64, `"` + label64 + `"` + notZone},
|
||||
{
|
||||
dnsblZones, tooLong,
|
||||
`"` + tooLong + `" is longer than 189 characters, too long for the names ` +
|
||||
`IPv6 clients are asked about by`,
|
||||
},
|
||||
{
|
||||
dnsblZones, dronebl + "," + spamhaus + "," + dronebl,
|
||||
`"` + dronebl + `" is listed twice`,
|
||||
},
|
||||
{dnsblResolver, "resolver.example", `"resolver.example"` + notResolver},
|
||||
{dnsblResolver, "192.0.2.53:0", `"192.0.2.53:0"` + notResolver},
|
||||
{dnsblResolver, "192.0.2.53:65536", `"192.0.2.53:65536"` + notResolver},
|
||||
{dnsblResolver, "[2001:db8::53]", `"[2001:db8::53]"` + notResolver},
|
||||
{
|
||||
reputationAction, "ban",
|
||||
`"ban" is not deny, limit:<percent> such as limit:25, or log`,
|
||||
},
|
||||
{reputationCacheTTL, off, `"off"` + notAboveZero},
|
||||
{reputationTimeout, "0s", `"0s"` + notAboveZero},
|
||||
} {
|
||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
||||
|
||||
want := tc.name + ": " + tc.want
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizesAndOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1704,6 +1836,7 @@ func writeFile(t *testing.T, contents string) string {
|
||||
return path
|
||||
}
|
||||
|
||||
//nolint:funlen // one line for each setting, a list that grows with them
|
||||
func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1749,6 +1882,11 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
blocklistURLs: "",
|
||||
blocklistRefresh: "24h",
|
||||
blocklistAction: actionDeny,
|
||||
dnsblZones: "",
|
||||
dnsblResolver: "",
|
||||
reputationAction: "limit:25",
|
||||
reputationCacheTTL: defaultReputationCacheTTL,
|
||||
reputationTimeout: "2s",
|
||||
banResponse: "403",
|
||||
limitBanDuration: "1h",
|
||||
limitBanRepeatWindow: "24h",
|
||||
|
||||
Reference in New Issue
Block a user