DNS blocklists asked in the background, verdicts kept (closes #104)
check / check (push) Waiting to run
check / check (push) Waiting to run
Work in progress. Model: opus-5-5
This commit is contained in:
+126
-3
@@ -152,6 +152,21 @@ type Config struct {
|
||||
BlocklistRefresh time.Duration
|
||||
BlocklistAction string
|
||||
BlocklistLimitPercent int64
|
||||
// DNSBLZones are the DNSBL zones clients are asked about
|
||||
// (SWWAF_DNSBL_ZONES), through DNSBLResolver (SWWAF_DNSBL_RESOLVER), or
|
||||
// the host's resolver while that is the zero AddrPort.
|
||||
// ReputationAction is what is done with a client a zone's verdict lists
|
||||
// (SWWAF_REPUTATION_ACTION): deny, limit or log; for limit,
|
||||
// ReputationLimitPercent is the percentage of every limit it gets. A
|
||||
// verdict is used for ReputationCacheTTL after it was fetched
|
||||
// (SWWAF_REPUTATION_CACHE_TTL), and a query may take ReputationTimeout
|
||||
// (SWWAF_REPUTATION_TIMEOUT). Neither can be off.
|
||||
DNSBLZones []string
|
||||
DNSBLResolver netip.AddrPort
|
||||
ReputationAction string
|
||||
ReputationLimitPercent int64
|
||||
ReputationCacheTTL time.Duration
|
||||
ReputationTimeout time.Duration
|
||||
// BanResponse is the status a refused client is answered with, 403
|
||||
// or 429, or 0 to close the connection without an answer
|
||||
// (SWWAF_BAN_RESPONSE). It answers a banned client, a request that
|
||||
@@ -366,6 +381,11 @@ var (
|
||||
errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h")
|
||||
errNotAction = errors.New(
|
||||
"is not deny, limit:<percent> such as limit:25, or log")
|
||||
errNotZone = errors.New("is not a DNS zone such as dnsbl.dronebl.org")
|
||||
errZoneTooLong = errors.New("is longer than 189 characters, too long for the " +
|
||||
"names IPv6 clients are asked about by")
|
||||
errNotResolver = errors.New("is not an IP address with an optional port, " +
|
||||
"such as 192.0.2.53 or [2001:db8::53]:5353")
|
||||
)
|
||||
|
||||
// FromEnvironment reads the settings with lookupEnv, normally
|
||||
@@ -418,6 +438,10 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||
ASNLimitPercentURL: env.listURL("SWWAF_ASN_LIMIT_PERCENT_URL"),
|
||||
BlocklistURLs: env.listURLs("SWWAF_BLOCKLIST_URLS"),
|
||||
BlocklistRefresh: env.refresh("SWWAF_BLOCKLIST_REFRESH", "24h"),
|
||||
DNSBLZones: env.zones("SWWAF_DNSBL_ZONES"),
|
||||
DNSBLResolver: env.resolver("SWWAF_DNSBL_RESOLVER"),
|
||||
ReputationCacheTTL: env.durationNotOff("SWWAF_REPUTATION_CACHE_TTL", "24h"),
|
||||
ReputationTimeout: env.durationNotOff("SWWAF_REPUTATION_TIMEOUT", "2s"),
|
||||
BanResponse: env.banResponse("SWWAF_BAN_RESPONSE", "403"),
|
||||
LimitBanDuration: env.durationNotOff("SWWAF_LIMIT_BAN_DURATION", "1h"),
|
||||
LimitBanRepeatWindow: env.durationNotOff("SWWAF_LIMIT_BAN_REPEAT_WINDOW", "24h"),
|
||||
@@ -462,6 +486,8 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||
cfg.InstanceName, cfg.LogRemoteURL != nil)
|
||||
cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action(
|
||||
"SWWAF_BLOCKLIST_ACTION", "deny")
|
||||
cfg.ReputationAction, cfg.ReputationLimitPercent = env.action(
|
||||
"SWWAF_REPUTATION_ACTION", "limit:25")
|
||||
|
||||
env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil)
|
||||
env.checkLookupDBPath(cfg)
|
||||
@@ -733,9 +759,9 @@ func (e *environment) refresh(name, defaultValue string) time.Duration {
|
||||
return duration
|
||||
}
|
||||
|
||||
// action reads a setting that is what is done with a client a list names:
|
||||
// deny, log, or limit:<percent>, which it returns as limit and the
|
||||
// percentage.
|
||||
// action reads a setting that is what is done with a client a blocklist
|
||||
// or a DNSBL zone lists: deny, log, or limit:<percent>, which it returns
|
||||
// as limit and the percentage.
|
||||
func (e *environment) action(name, defaultValue string) (string, int64) {
|
||||
value := e.value(name, defaultValue)
|
||||
if value == "deny" || value == "log" {
|
||||
@@ -752,6 +778,24 @@ func (e *environment) action(name, defaultValue string) (string, int64) {
|
||||
return "limit", percent
|
||||
}
|
||||
|
||||
// zones reads the setting that is the list of DNSBL zones. It is empty by
|
||||
// default.
|
||||
func (e *environment) zones(name string) []string {
|
||||
zones, err := parseZones(e.value(name, ""))
|
||||
e.check(name, err)
|
||||
|
||||
return zones
|
||||
}
|
||||
|
||||
// resolver reads the setting that is the resolver the DNSBL zones are
|
||||
// asked through, the zero AddrPort while it is unset or empty.
|
||||
func (e *environment) resolver(name string) netip.AddrPort {
|
||||
resolver, err := parseResolver(e.value(name, ""))
|
||||
e.check(name, err)
|
||||
|
||||
return resolver
|
||||
}
|
||||
|
||||
// lookupSource reads the setting that is where clients are looked up:
|
||||
// geojs, file, or off.
|
||||
func (e *environment) lookupSource(name, defaultValue string) string {
|
||||
@@ -1697,6 +1741,85 @@ func parseListURLs(value string) ([]string, error) {
|
||||
return urls, nil
|
||||
}
|
||||
|
||||
const (
|
||||
// maxZoneLength is the most characters a DNSBL zone may have: 253, the
|
||||
// most a DNS name may have, less the 64 that come before the zone in
|
||||
// the name an IPv6 client is asked about by, its 32 hex digits each
|
||||
// followed by a dot.
|
||||
maxZoneLength = 189
|
||||
// maxLabelLength is the most characters a label of a DNS name may have.
|
||||
maxLabelLength = 63
|
||||
// labelChars are the characters a label of a DNS zone may hold.
|
||||
labelChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"
|
||||
// dnsPort is the port a resolver is asked on when SWWAF_DNSBL_RESOLVER
|
||||
// gives none.
|
||||
dnsPort = 53
|
||||
)
|
||||
|
||||
// parseZones reads a comma-separated list of DNSBL zones, each a DNS name
|
||||
// such as dnsbl.dronebl.org: labels separated by dots, each of 1 to 63
|
||||
// letters, digits and hyphens, neither starting nor ending with a hyphen,
|
||||
// and at most maxZoneLength characters in all. Go's resolver takes any
|
||||
// other name for one that does not exist, so that the zone would list no
|
||||
// client. A zone listed twice is an error.
|
||||
func parseZones(value string) ([]string, error) {
|
||||
zones, err := parseList(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i, zone := range zones {
|
||||
switch {
|
||||
case len(zone) > maxZoneLength:
|
||||
return nil, fmt.Errorf("%q %w", zone, errZoneTooLong)
|
||||
case !isZone(zone):
|
||||
return nil, fmt.Errorf("%q %w", zone, errNotZone)
|
||||
case slices.Contains(zones[:i], zone):
|
||||
return nil, fmt.Errorf("%q %w", zone, errListedTwice)
|
||||
}
|
||||
}
|
||||
|
||||
return zones, nil
|
||||
}
|
||||
|
||||
// isZone reports whether each label of zone is as parseZones takes it.
|
||||
func isZone(zone string) bool {
|
||||
for label := range strings.SplitSeq(zone, ".") {
|
||||
badChar := strings.ContainsFunc(label, func(char rune) bool {
|
||||
return !strings.ContainsRune(labelChars, char)
|
||||
})
|
||||
|
||||
if label == "" || len(label) > maxLabelLength || badChar ||
|
||||
strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// parseResolver reads the resolver the DNSBL zones are asked through: an
|
||||
// IP address with a port from 1 to 65535, such as 192.0.2.53:5353 or
|
||||
// [2001:db8::53]:5353, or without one, such as 192.0.2.53 or 2001:db8::53,
|
||||
// for port 53. An empty value is none, the zero AddrPort.
|
||||
func parseResolver(value string) (netip.AddrPort, error) {
|
||||
if value == "" {
|
||||
return netip.AddrPort{}, nil
|
||||
}
|
||||
|
||||
resolver, err := netip.ParseAddrPort(value)
|
||||
if err == nil && resolver.Port() != 0 {
|
||||
return resolver, nil
|
||||
}
|
||||
|
||||
addr, err := netip.ParseAddr(value)
|
||||
if err != nil {
|
||||
return netip.AddrPort{}, fmt.Errorf("%q %w", value, errNotResolver)
|
||||
}
|
||||
|
||||
return netip.AddrPortFrom(addr, dnsPort), nil
|
||||
}
|
||||
|
||||
// parseWebhookHeaders reads a comma-separated list of headers, each its
|
||||
// name, :, and its value, and returns them, and how the log shows them,
|
||||
// with each value as ********. An error names the item by its place in
|
||||
|
||||
Reference in New Issue
Block a user