Request tags, with refusing and limits per tag (closes #175)
check / check (push) Successful in 3m36s

Each request gets sorted tags from the lookup, new address lists, the
reputation sources and the rule matches, logged as `tags`. New settings:
`SWW_TAG_LISTS`, `SWW_TAG_DENY`, `SWW_TAG_LIMIT_PERCENT`. They replace
`SWW_ASN_LIMIT_PERCENT`, `SWW_COUNTRY_LIMIT_PERCENT` and
`SWW_DENIED_COUNTRIES`. New heuristics `tag_lists` and `tag_deny`; the
latter runs before the rate limits and again after the Core Rule Set.

Model: opus-5-5
This commit is contained in:
sneak committed 2026-11-09 00:39:44 +00:00
1 parent 2349b2a081
commit 60630859af
42 files changed
+1088 -319

No files matched your search

+243 -26
View File
@@ -93,29 +93,30 @@ type Config struct {
LookupDBPath string
LookupTimeout time.Duration
AddLookupHeaders bool
// DeniedCountries are the countries whose clients are refused
// (SWW_DENIED_COUNTRIES). ExclusivelyAllowedCountries, when not
// empty, are the only countries whose clients are let through
// (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES). Both hold two-letter codes in
// capitals, as GeoJS gives them.
DeniedCountries []string
// ExclusivelyAllowedCountries, when not empty, are the only countries
// whose clients are let through (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES),
// two-letter codes in capitals, as GeoJS gives them.
ExclusivelyAllowedCountries []string
// The biased thresholds. ASNLimitPercent and CountryLimitPercent give
// the clients of the AS numbers and the countries they list that
// percentage of every rate limit and byte limit
// (SWW_ASN_LIMIT_PERCENT and SWW_COUNTRY_LIMIT_PERCENT).
// ASNBytesPercent and CountryBytesPercent give those they list a
// percentage of the byte limits in place of that one
// TagLists are the address lists that each add a tag to the requests of
// the clients they hold (SWW_TAG_LISTS). TagDeny are the tags whose
// requests are refused (SWW_TAG_DENY). TagLimitPercent gives the clients
// with a tag that percentage of every rate limit and byte limit, from 1
// to 99 (SWW_TAG_LIMIT_PERCENT). All hold tags in lower case, as requests
// carry them.
TagLists []TagList
TagDeny []string
TagLimitPercent map[string]int64
// The biased thresholds. ASNBytesPercent and CountryBytesPercent give
// those they list a percentage of the byte limits in place of the one
// TagLimitPercent gives their asn= or country= tag
// (SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT). Each holds
// percentages from 0 to 100, by AS number, written as AS64496, or by
// country, a two-letter code in capitals, as the lookup gives them.
// UnknownLimitPercent is the percentage of every limit a client without
// a country gets (SWW_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
// where a file of AS:percent lines is fetched from, whose percentages
// count as those of ASNLimitPercent do (SWW_ASN_LIMIT_PERCENT_URL), ""
// while it is unset.
ASNLimitPercent map[string]int64
CountryLimitPercent map[string]int64
// count as asn= tags in TagLimitPercent do (SWW_ASN_LIMIT_PERCENT_URL),
// "" while it is unset.
ASNBytesPercent map[string]int64
CountryBytesPercent map[string]int64
UnknownLimitPercent int64
@@ -367,7 +368,7 @@ var (
errHeaderTakenOut = errors.New(
"is taken out of every request by Go's HTTP server, so it can never " +
"be logged")
errOnBothLists = errors.New("is in SWW_DENIED_COUNTRIES too")
errOnBothLists = errors.New("is in SWW_TAG_DENY too")
errNotLookupSource = errors.New("is not geojs, file or off")
errNeedsLookups = errors.New("it needs each client looked up")
errNeedsDBPath = errors.New("it names the file to look clients up in")
@@ -451,6 +452,18 @@ var (
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
errAnotherList = errors.New(
"is in SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too")
errFetchedOtherwise = errors.New("is in SWW_BLOCKLIST_URLS, is " +
"SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too")
errNotTagList = errors.New(
"is not a name, = and an http or https URL, such as " +
"torexit=https://example.com/tor.txt")
errNotTagPercentItem = errors.New(
"is not a tag, : and a percentage, such as vpn:50 or asn=14061:25")
errNotTagPercent = errors.New("is not a percentage from 1 to 99")
errNotASNTag = errors.New("is not asn= and a number, such as asn=14061")
errExploitTag = errors.New(
"cannot lower limits: the rate limits count a request before its " +
"rules are matched")
)
// FromEnvironment reads the settings with lookupEnv, normally
@@ -483,11 +496,11 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
LookupDBPath: env.value("SWW_LOOKUP_DB_PATH", ""),
LookupTimeout: env.durationNotOff("SWW_LOOKUP_TIMEOUT", "800ms"),
AddLookupHeaders: env.boolean("SWW_ADD_LOOKUP_HEADERS", "false"),
DeniedCountries: env.countries("SWW_DENIED_COUNTRIES", ""),
ExclusivelyAllowedCountries: env.countries(
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
ASNLimitPercent: env.percents("SWW_ASN_LIMIT_PERCENT", ParseASN),
CountryLimitPercent: env.percents("SWW_COUNTRY_LIMIT_PERCENT", parseCountry),
TagLists: env.tagLists("SWW_TAG_LISTS"),
TagDeny: env.tags("SWW_TAG_DENY"),
TagLimitPercent: env.tagPercents("SWW_TAG_LIMIT_PERCENT"),
ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN),
CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry),
UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"),
@@ -567,6 +580,7 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
env.checkCountriesAndLookups(cfg)
env.checkASNLimitPercentURL(cfg)
env.checkCrowdSec(cfg)
env.checkTagLists(cfg)
if env.err != nil {
return nil, env.err
@@ -841,6 +855,31 @@ func (e *environment) percents(
return percents
}
// tagLists reads SWW_TAG_LISTS, which is empty by default.
func (e *environment) tagLists(name string) []TagList {
lists, err := parseTagLists(e.value(name, ""))
e.check(name, err)
return lists
}
// tags reads a setting that is a list of tags. It is empty by default.
func (e *environment) tags(name string) []string {
tags, err := parseTags(e.value(name, ""))
e.check(name, err)
return tags
}
// tagPercents reads a setting that is a list of tags, each with a
// percentage. It is empty by default.
func (e *environment) tagPercents(name string) map[string]int64 {
percents, err := parseTagPercents(e.value(name, ""))
e.check(name, err)
return percents
}
// percent reads a setting that is a percentage, from 0 to 100.
func (e *environment) percent(name, defaultValue string) int64 {
percent, err := ParsePercent(e.value(name, defaultValue))
@@ -975,15 +1014,17 @@ func (e *environment) checkLookupDBPath(cfg *Config) {
}
}
// checkCountriesAndLookups refuses a country on both country lists, and,
// checkCountriesAndLookups refuses a country in
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES whose tag is in SWW_TAG_DENY, and,
// while SWW_LOOKUP_SOURCE is off, each setting that needs clients looked
// up: the country lists, SWW_ADD_LOOKUP_HEADERS, the biased thresholds,
// up: the country list, SWW_ADD_LOOKUP_HEADERS, the tag settings that name
// an asn= or country= tag, the biased thresholds,
// SWW_ASN_LIMIT_PERCENT_URL among them, of which
// SWW_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a
// limit, and the anomaly thresholds per AS number.
func (e *environment) checkCountriesAndLookups(cfg *Config) {
for _, country := range cfg.ExclusivelyAllowedCountries {
if slices.Contains(cfg.DeniedCountries, country) {
if slices.Contains(cfg.TagDeny, countryTagPrefix+strings.ToLower(country)) {
e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
fmt.Errorf("%q %w", country, errOnBothLists))
}
@@ -997,11 +1038,10 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
name string
set bool
}{
{"SWW_DENIED_COUNTRIES", len(cfg.DeniedCountries) > 0},
{"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
{"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
{"SWW_ASN_LIMIT_PERCENT", len(cfg.ASNLimitPercent) > 0},
{"SWW_COUNTRY_LIMIT_PERCENT", len(cfg.CountryLimitPercent) > 0},
{"SWW_TAG_DENY", slices.ContainsFunc(cfg.TagDeny, isLookupTag)},
{"SWW_TAG_LIMIT_PERCENT", tagLimitNeedsLookups(cfg)},
{"SWW_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
{"SWW_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
{"SWW_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
@@ -1047,6 +1087,40 @@ func (e *environment) checkCrowdSec(cfg *Config) {
}
}
// checkTagLists refuses a list of SWW_TAG_LISTS whose URL is fetched as
// another list too: the file at a URL is read as one kind of list.
func (e *environment) checkTagLists(cfg *Config) {
for _, list := range cfg.TagLists {
if slices.Contains(cfg.BlocklistURLs, list.URL) ||
list.URL == cfg.ASNLimitPercentURL || list.URL == cfg.CrowdSecDecisionsURL {
e.check("SWW_TAG_LISTS", fmt.Errorf("%q %w", list.URL, errFetchedOtherwise))
}
}
}
// TagsNeedLookup reports whether SWW_TAG_DENY or SWW_TAG_LIMIT_PERCENT
// names an asn= or country= tag, which only the lookup can give a request.
func (c *Config) TagsNeedLookup() bool {
return slices.ContainsFunc(c.TagDeny, isLookupTag) || tagLimitNeedsLookups(c)
}
// tagLimitNeedsLookups reports whether SWW_TAG_LIMIT_PERCENT names an asn=
// or country= tag.
func tagLimitNeedsLookups(cfg *Config) bool {
for tag := range cfg.TagLimitPercent {
if isLookupTag(tag) {
return true
}
}
return false
}
// isLookupTag reports whether tag is an asn= or country= tag.
func isLookupTag(tag string) bool {
return strings.HasPrefix(tag, asnTagPrefix) || strings.HasPrefix(tag, countryTagPrefix)
}
// headerNames reads a setting that is a list of header names, and
// returns them in lower case.
func (e *environment) headerNames(name, defaultValue string) []string {
@@ -1752,6 +1826,149 @@ func ParseASN(value string) (string, error) {
return "AS" + strconv.FormatUint(number, 10), nil
}
// The prefixes of the tags the lookup gives a request.
const (
asnTagPrefix = "asn="
countryTagPrefix = "country="
)
// ExploitSignatureTag is the tag of a request a rule file or the Core Rule
// Set matched.
const ExploitSignatureTag = "exploitsignature"
// TagList is an address list of SWW_TAG_LISTS: the tag it adds to the
// requests of the clients it holds, and the URL it is fetched from.
type TagList struct {
Name string
URL string
}
// parseTagLists reads a comma-separated list of name=URL pairs, such as
// torexit=https://example.com/tor.txt, and returns each list with its name
// in lower case. A name can hold a =, as vpn=mullvad does, and ends at the
// = before http. A name or a URL listed twice is an error.
func parseTagLists(value string) ([]TagList, error) {
items, err := parseList(value)
if err != nil {
return nil, err
}
lists := make([]TagList, 0, len(items))
for _, item := range items {
end := strings.Index(item, "=http")
if end <= 0 || !isListURL(item[end+1:]) {
return nil, fmt.Errorf("%q %w", item, errNotTagList)
}
list := TagList{Name: strings.ToLower(item[:end]), URL: item[end+1:]}
if slices.ContainsFunc(lists, func(earlier TagList) bool {
return earlier.Name == list.Name || earlier.URL == list.URL
}) {
return nil, fmt.Errorf("%q %w", item, errListedTwice)
}
lists = append(lists, list)
}
return lists, nil
}
// parseTags reads a comma-separated list of tags, which parseTag reads. A
// tag listed twice is an error.
func parseTags(value string) ([]string, error) {
items, err := parseList(value)
if err != nil {
return nil, err
}
tags := make([]string, 0, len(items))
for _, item := range items {
tag, err := parseTag(item)
if err != nil {
return nil, err
}
if slices.Contains(tags, tag) {
return nil, fmt.Errorf("%q %w", item, errListedTwice)
}
tags = append(tags, tag)
}
return tags, nil
}
// parseTag reads a tag in either case, and returns it as a request carries
// it: in lower case, an asn= tag's number without leading zeros. An asn= or
// country= tag must name a number or a country, so that a mistyped one
// stops the start instead of never matching.
func parseTag(value string) (string, error) {
tag := strings.ToLower(value)
if digits, isASN := strings.CutPrefix(tag, asnTagPrefix); isASN {
number, err := strconv.ParseUint(digits, 10, 32)
if err != nil {
return "", fmt.Errorf("%q %w", value, errNotASNTag)
}
return asnTagPrefix + strconv.FormatUint(number, 10), nil
}
if code, isCountry := strings.CutPrefix(tag, countryTagPrefix); isCountry {
_, err := parseCountry(code)
if err != nil {
return "", err
}
}
return tag, nil
}
// parseTagPercents reads a comma-separated list of tags, each with : and a
// percentage from 1 to 99, such as vpn:50 or asn=14061:25. A tag listed
// twice is an error, and so is ExploitSignatureTag, which a request gets
// only after the rate limits have counted it.
func parseTagPercents(value string) (map[string]int64, error) {
items, err := parseList(value)
if err != nil {
return nil, err
}
percents := make(map[string]int64, len(items))
for _, item := range items {
colon := strings.LastIndex(item, ":")
if colon <= 0 {
return nil, fmt.Errorf("%q %w", item, errNotTagPercentItem)
}
tag, err := parseTag(item[:colon])
if err != nil {
return nil, err
}
percent, err := strconv.ParseInt(item[colon+1:], 10, 64)
if err != nil || percent < 1 || percent > 99 {
return nil, fmt.Errorf("%q %w", item[colon+1:], errNotTagPercent)
}
if tag == ExploitSignatureTag {
return nil, fmt.Errorf("%q %w", tag, errExploitTag)
}
if _, listed := percents[tag]; listed {
return nil, fmt.Errorf("%q %w", item[:colon], errListedTwice)
}
percents[tag] = percent
}
return percents, nil
}
// parsePercents reads a comma-separated list of items, each an AS number
// or a country, which parseCode reads, then : and a percentage, such as
// AS64496:50 or cn:25, and returns each one's percentage. An empty value
+123 -49
View File
@@ -42,10 +42,10 @@ const (
lookupDBPath = "SWW_LOOKUP_DB_PATH"
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
deniedCountries = "SWW_DENIED_COUNTRIES"
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
asnLimitPercent = "SWW_ASN_LIMIT_PERCENT"
countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT"
tagLists = "SWW_TAG_LISTS"
tagDeny = "SWW_TAG_DENY"
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
@@ -274,9 +274,13 @@ func TestDefaults(t *testing.T) {
wantNetblocks(t, cfg.AllowNets)
wantNetblocks(t, cfg.RateLimitExemptNets)
wantNetblocks(t, cfg.DenyNets)
wantCountries(t, deniedCountries, cfg.DeniedCountries)
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries)
if len(cfg.TagLists) != 0 || len(cfg.TagDeny) != 0 || len(cfg.TagLimitPercent) != 0 {
t.Errorf("tag settings %v, %v and %v by default, want all empty",
cfg.TagLists, cfg.TagDeny, cfg.TagLimitPercent)
}
hostname, err := os.Hostname()
if err != nil || hostname == "" || cfg.InstanceName != hostname {
t.Errorf("%s is %q, want the host's name %q (%v)", instanceName,
@@ -327,7 +331,6 @@ func TestValuesAsSet(t *testing.T) {
rateLimitPerMinute: "60",
rateLimitPerHour: "600",
rateLimitPerDay: "6000",
deniedCountries: "cn, RU,kp,Xk",
allowedCountries: "de",
banResponse: "429",
limitBanDuration: "15m",
@@ -370,7 +373,6 @@ func TestValuesAsSet(t *testing.T) {
wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32")
wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32")
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
wantCountries(t, deniedCountries, cfg.DeniedCountries, "CN", "RU", "KP", "XK")
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
}
@@ -1101,7 +1103,7 @@ func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(environment{
deniedCountries: "cn,ru",
tagDeny: "country=cn,country=ru",
allowedCountries: "de,RU",
}.lookupEnv)
if err == nil {
@@ -1176,12 +1178,11 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
t.Parallel()
for name, value := range map[string]string{
deniedCountries: "kp",
allowedCountries: "de",
addLookupHeaders: enabled,
tagDeny: "country=kp",
tagLimitPercent: "asn=64496:50",
asnLimitPercentURL: asnURL,
asnLimitPercent: "AS64496:50",
countryLimitPercent: "cn:25",
asnBytesPercent: "AS64496:50",
countryBytesPercent: "cn:25",
unknownLimitPercent: "99",
@@ -1204,12 +1205,12 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
}
// Set empty, the lists need nothing looked up, and nor does
// SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly
// threshold per AS number that is off, any other anomaly threshold, or
// a blocklist.
// SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, a tag the
// lookup does not give, an anomaly threshold per AS number that is off,
// any other anomaly threshold, or a blocklist.
env := environment{
lookupSource: off, deniedCountries: "", allowedCountries: "",
asnLimitPercent: "", countryLimitPercent: "", asnBytesPercent: "",
lookupSource: off, allowedCountries: "", asnBytesPercent: "",
tagDeny: torExitTag, tagLimitPercent: "vpn:50",
countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "",
blocklistURLs: dropURL,
}
@@ -1355,34 +1356,96 @@ func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
}
}
// torExitTag is a tag these tests name.
const torExitTag = "torexit"
func TestTagSettingsAsSet(t *testing.T) {
t.Parallel()
const torURL = "https://example.com/tor.txt"
// Names and tags in either case, an AS number with leading zeros, and a
// name holding a =.
cfg := fromEnvironment(t, environment{
tagLists: "TorExit=" + torURL +
", vpn=mullvad=https://example.com/mullvad.txt",
tagDeny: "TorExit, Country=KP, asn=014061",
tagLimitPercent: "vpn:50, asn=014061:25, COUNTRY=cn:1, dnsbl=zen.example:99",
})
wantLists := []config.TagList{
{Name: torExitTag, URL: torURL},
{Name: "vpn=mullvad", URL: "https://example.com/mullvad.txt"},
}
if !slices.Equal(cfg.TagLists, wantLists) {
t.Errorf("%s gave %+v, want %+v", tagLists, cfg.TagLists, wantLists)
}
wantDeny := []string{torExitTag, "country=kp", "asn=14061"}
if !slices.Equal(cfg.TagDeny, wantDeny) {
t.Errorf("%s gave %v, want %v", tagDeny, cfg.TagDeny, wantDeny)
}
wantPercents := map[string]int64{
"vpn": 50, "asn=14061": 25, "country=cn": 1, "dnsbl=zen.example": 99,
}
if !maps.Equal(cfg.TagLimitPercent, wantPercents) {
t.Errorf("%s gave %v, want %v", tagLimitPercent, cfg.TagLimitPercent, wantPercents)
}
}
func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
t.Parallel()
const lapi = "http://172.17.0.1:8080"
for _, tc := range []struct {
name, listURL string
env environment
}{
{"a blocklist", dropURL, environment{blocklistURLs: dropURL}},
{"the file of AS:percent lines", asnURL, environment{asnLimitPercentURL: asnURL}},
{
"the CrowdSec decision list", lapi + "/v1/decisions",
environment{"SWW_CROWDSEC_LAPI_URL": lapi, "SWW_CROWDSEC_LAPI_KEY": "key"},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tc.env[tagLists] = "dup=" + tc.listURL
want := tagLists + `: "` + tc.listURL + `" is in SWW_BLOCKLIST_URLS, is ` +
"SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too"
_, err := config.FromEnvironment(tc.env.lookupEnv)
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
})
}
}
func TestBiasedThresholdsAsSet(t *testing.T) {
t.Parallel()
cfg := fromEnvironment(t, environment{})
if len(cfg.ASNLimitPercent) != 0 || len(cfg.CountryLimitPercent) != 0 ||
len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
if len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
cfg.UnknownLimitPercent != 100 {
t.Errorf("biased thresholds %v, %v, %v, %v and %d by default, "+
"want four empty lists and 100", cfg.ASNLimitPercent, cfg.CountryLimitPercent,
t.Errorf("biased thresholds %v, %v and %d by default, "+
"want two empty lists and 100",
cfg.ASNBytesPercent, cfg.CountryBytesPercent, cfg.UnknownLimitPercent)
}
// AS numbers and countries in either case, an AS number with leading
// zeros, 0 and 100.
cfg = fromEnvironment(t, environment{
asnLimitPercent: "AS14061:50, as16276:0,AS045102:100",
countryLimitPercent: "cn:25,RU:50",
asnBytesPercent: "as16276:75",
countryBytesPercent: "ru:10",
unknownLimitPercent: "0",
})
for name, tc := range map[string]struct{ got, want map[string]int64 }{
asnLimitPercent: {
cfg.ASNLimitPercent,
map[string]int64{"AS14061": 50, "AS16276": 0, "AS45102": 100},
},
countryLimitPercent: {cfg.CountryLimitPercent, map[string]int64{"CN": 25, "RU": 50}},
asnBytesPercent: {cfg.ASNBytesPercent, map[string]int64{"AS16276": 75}},
countryBytesPercent: {cfg.CountryBytesPercent, map[string]int64{"RU": 10}},
} {
@@ -1406,16 +1469,16 @@ func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(t *testing.T) {
)
for _, tc := range []struct{ name, value, want string }{
{asnLimitPercent, "14061:50", `"14061"` + notASN},
{asnLimitPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
{asnLimitPercent, "AS14061", `"AS14061"` + notItem},
{asnLimitPercent, "AS14061:101", `"101"` + notPercent},
{asnLimitPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
{asnBytesPercent, "14061:50", `"14061"` + notASN},
{asnBytesPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
{asnBytesPercent, "AS14061", `"AS14061"` + notItem},
{asnBytesPercent, "AS14061:101", `"101"` + notPercent},
{asnBytesPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
{
countryLimitPercent, "nk:25",
countryBytesPercent, "nk:25",
`"nk" is not a two-letter country code such as de or kp`,
},
{countryLimitPercent, "cn:25,CN:50", `"CN" is listed twice`},
{countryBytesPercent, "cn:25,CN:50", `"CN" is listed twice`},
{asnBytesPercent, "AS14061:-1", `"-1"` + notPercent},
{countryBytesPercent, "cn:50%", `"50%"` + notPercent},
{unknownLimitPercent, "101", `"101"` + notPercent},
@@ -2003,23 +2066,34 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
{lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""},
{lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"},
{addLookupHeaders, "yes"},
{deniedCountries, "nk"},
{deniedCountries, "kp,,ir"},
{deniedCountries, "prk"},
{deniedCountries, "408"},
{deniedCountries, "k"},
{deniedCountries, "eu"},
{deniedCountries, "un"},
{deniedCountries, "su"},
{allowedCountries, "nk"},
{allowedCountries, "kp,,ir"},
{allowedCountries, "prk"},
{allowedCountries, "408"},
{allowedCountries, "k"},
{allowedCountries, "eu"},
{allowedCountries, "un"},
{allowedCountries, "su"},
{allowedCountries, "ac"},
{allowedCountries, "uk"},
{allowedCountries, "zz"},
{allowedCountries, "de,germany"},
{asnLimitPercent, "AS14061:50,,AS16276:50"}, {asnLimitPercent, "ASX:50"},
{asnLimitPercent, "AS14061:"}, {asnLimitPercent, "AS14061 :50"},
{asnLimitPercent, "AS14061:1.5"}, {asnLimitPercent, "AS-1:50"},
{countryLimitPercent, "cn"}, {countryLimitPercent, "cn:"},
{countryLimitPercent, "cn:25:50"}, {countryLimitPercent, "china:25"},
{asnBytesPercent, "AS14061:50,,AS16276:50"}, {asnBytesPercent, "ASX:50"},
{asnBytesPercent, "AS14061:"}, {asnBytesPercent, "AS14061 :50"},
{asnBytesPercent, "AS14061:1.5"}, {asnBytesPercent, "AS-1:50"},
{countryBytesPercent, "cn"}, {countryBytesPercent, "cn:"},
{countryBytesPercent, "cn:25:50"}, {countryBytesPercent, "china:25"},
{tagDeny, "a,,b"}, {tagDeny, "country=nk"}, {tagDeny, "asn=abc"},
{tagDeny, "asn="}, {tagDeny, "torexit,TOREXIT"},
{tagLimitPercent, "vpn"}, {tagLimitPercent, ":50"}, {tagLimitPercent, "vpn:"},
{tagLimitPercent, "vpn:0"}, {tagLimitPercent, "vpn:100"},
{tagLimitPercent, "vpn:1.5"}, {tagLimitPercent, "vpn:50,VPN:25"},
{tagLimitPercent, "exploitsignature:50"}, {tagLimitPercent, "asn=abc:50"},
{tagLists, torExitTag}, {tagLists, "=https://example.com/a.txt"},
{tagLists, "a,b=https://example.com/a.txt"},
{tagLists, "a=ftp://example.com/a.txt"},
{tagLists, "a=https://example.com/a.txt,A=https://example.com/b.txt"},
{tagLists, "a=https://example.com/a.txt,b=https://example.com/a.txt"},
{asnBytesPercent, "AS14061:101"}, {countryBytesPercent, "su:50"},
{unknownLimitPercent, ""}, {unknownLimitPercent, "-1"},
{unknownLimitPercent, "50%"},
@@ -2286,10 +2360,10 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
lookupDBPath: "",
lookupTimeout: "800ms",
addLookupHeaders: "false",
deniedCountries: "",
allowedCountries: "",
asnLimitPercent: "",
countryLimitPercent: "",
tagLists: "",
tagDeny: "",
tagLimitPercent: "",
asnBytesPercent: "",
countryBytesPercent: "",
unknownLimitPercent: "100",
+9 -7
View File
@@ -13,13 +13,14 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// abuseIPDB notes AbuseIPDB, as noteHit does, with the score, when its score
// of the client is a hit, and refuses the request when SWW_REPUTATION_ACTION
// is deny, as dnsbl does for a zone. While SWW_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. Its figures are the score and SWW_ABUSEIPDB_MIN_SCORE.
// abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with
// the score, when its score of the client is a hit, and refuses the request
// when SWW_REPUTATION_ACTION is deny, as dnsbl does for a zone. While
// SWW_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. Its figures are the score and
// SWW_ABUSEIPDB_MIN_SCORE.
type abuseIPDB struct{}
func (abuseIPDB) name() string { return "abuseipdb" }
@@ -39,6 +40,7 @@ func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
}
rq.abuseIPDBHit = true
rq.addTag("abuseipdb")
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
+2 -2
View File
@@ -335,7 +335,7 @@ func newDiscriminatorInBubble(
) (*discriminator.Server, *output) {
t.Helper()
settings := map[string]string{deniedCountries: "KP"}
settings := map[string]string{tagDeny: "country=KP"}
maps.Copy(settings, env)
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, settings)
@@ -542,7 +542,7 @@ func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
return discriminator.New(params)
}, held, time.Now, map[string]string{
deniedCountries: "KP", wafMode: block,
tagDeny: "country=KP", wafMode: block,
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
abuseIPDBKey: accountKey,
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
+4 -4
View File
@@ -211,7 +211,7 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
lookupTimeout: "1h",
rateLimitPerMinute: "1",
banScopeV4Prefix: "24",
deniedCountries: "kp",
tagDeny: denyKP,
})
// fromDE's ban covers otherClient, which is refused unasked about.
@@ -266,7 +266,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
lookupTimeout: "1h",
rateLimitPerMinute: "1",
denyNets: denied,
deniedCountries: "kp",
tagDeny: denyKP,
}
if tc.setting != "" {
@@ -276,7 +276,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
s, _, _ := startWithClock(t, geojsURL, env)
s.get(denied, tc.status, requestlog.ActionDenied)
s.get(fromKP, tc.status, requestlog.ActionCountryDenied)
s.get(fromKP, tc.status, requestlog.ActionDenied)
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
s.get(fromDE, tc.status, requestlog.ActionRateLimited)
s.get(fromDE, tc.status, requestlog.ActionBanned)
@@ -291,7 +291,7 @@ func TestBanNotes(t *testing.T) {
s, clk, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "1",
deniedCountries: "kp",
tagDeny: denyKP,
})
start := clk.Now()
+36 -26
View File
@@ -17,30 +17,31 @@ type percentage struct {
}
// biasedThresholdsSet reports whether a biased threshold can lower a
// client's limits: one of its lists is not empty,
// SWW_UNKNOWN_LIMIT_PERCENT is below 100, or SWW_ASN_LIMIT_PERCENT_URL
// is set. The client's lookup is then needed before its request goes on.
// client's limits by its lookup: SWW_ASN_BYTES_PERCENT or
// SWW_COUNTRY_BYTES_PERCENT is not empty, SWW_UNKNOWN_LIMIT_PERCENT is
// below 100, or SWW_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
// then needed before its request goes on.
func biasedThresholdsSet(cfg *config.Config) bool {
return len(cfg.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 0 ||
len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
return len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != ""
}
// limitPercentages returns the client's limit percentages, for the rate
// limits and for the byte limits, by its AS number and country as looked
// up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB
// that list it. Each is the lowest of those the settings give it, the
// first of them in the order below when several are lowest: the
// percentage SWW_ASN_LIMIT_PERCENT gives its AS number, the one the file
// SWW_ASN_LIMIT_PERCENT_URL names gives it, the one
// SWW_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
// country, unless its request skipped the wait for GeoJS,
// SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, the
// percentage of SWW_BLOCKLIST_ACTION while it is limit, and for a client
// a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit, the
// percentage of SWW_REPUTATION_ACTION while it is limit. For the byte
// limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT take the
// place of the first three for an AS number or a country they list.
// limits and for the byte limits, by its tags, its AS number and country
// as looked up, each "" when unknown, and the blocklists, DNSBL zones and
// AbuseIPDB that list it. Each is the lowest of those the settings give
// it, the first of them in the order below when several are lowest: the
// percentage SWW_TAG_LIMIT_PERCENT gives its asn= tag, the one the file
// SWW_ASN_LIMIT_PERCENT_URL names gives its AS number, the one
// SWW_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its
// other tags, for a client without a country, unless its request skipped
// the wait for GeoJS, SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist
// lists, the percentage of SWW_BLOCKLIST_ACTION while it is limit, and
// for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is
// a hit, the percentage of SWW_REPUTATION_ACTION while it is limit. For
// the byte limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT
// take the place of the first three for an AS number or a country they
// list.
func (rq *request) limitPercentages() (percentage, percentage) {
cfg := rq.h.config
asn, country := rq.line.ASN, rq.line.Country
@@ -65,10 +66,18 @@ func (rq *request) limitPercentages() (percentage, percentage) {
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"}
}
asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWW_ASN_LIMIT_PERCENT"),
fetched)
countryRequests := given(cfg.CountryLimitPercent, country,
"SWW_COUNTRY_LIMIT_PERCENT")
const tagSetting = "SWW_TAG_LIMIT_PERCENT"
asnRequests := lowest(given(cfg.TagLimitPercent, asnTag(asn), tagSetting), fetched)
countryRequests := given(cfg.TagLimitPercent, countryTag(country), tagSetting)
otherTags := percentage{percent: whole}
for _, tag := range rq.line.Tags {
if tag != asnTag(asn) && tag != countryTag(country) {
otherTags = lowest(otherTags, given(cfg.TagLimitPercent, tag, tagSetting))
}
}
asnBytes, countryBytes := asnRequests, countryRequests
if _, listed := cfg.ASNBytesPercent[asn]; listed {
@@ -79,12 +88,13 @@ func (rq *request) limitPercentages() (percentage, percentage) {
countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT")
}
return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed),
lowest(asnBytes, countryBytes, unknown, blocklisted, reputationListed)
return lowest(asnRequests, countryRequests, otherTags, unknown, blocklisted,
reputationListed),
lowest(asnBytes, countryBytes, otherTags, unknown, blocklisted, reputationListed)
}
// given returns the percentage percents, the setting named setting, gives
// code, an AS number or a country, or whole when it does not list code.
// code, an AS number, a country or a tag, or whole when it does not list code.
func given(percents map[string]int64, code, setting string) percentage {
percent, listed := percents[code]
if !listed {
+38 -30
View File
@@ -22,8 +22,7 @@ import (
// The biased thresholds.
const (
asnLimitPercent = "SWW_ASN_LIMIT_PERCENT"
countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT"
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
@@ -35,6 +34,11 @@ const (
asnDEHalf = asnDE + ":50"
asnDEQuarter = asnDE + ":25"
countryDEHalf = "de:50"
// The same, as SWW_TAG_LIMIT_PERCENT names fromDE's tags.
asnTagDEHalf = "asn=64496:50"
asnTagDEQuarter = "asn=64496:25"
countryTagDEHalf = "country=de:50"
countryTagDEQuarter = "country=de:25"
// noCountry is in an AS of its own, AS64500, and in no country.
noCountry = "192.0.2.80"
// fourAMinute is the rate limit these tests set: half of it is 2
@@ -54,8 +58,8 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
for _, tc := range []struct {
setting, value, from string
}{
{asnLimitPercent, asnDEHalf, fromDE},
{countryLimitPercent, countryDEHalf, fromDE},
{tagLimitPercent, asnTagDEHalf, fromDE},
{tagLimitPercent, countryTagDEHalf, fromDE},
{unknownLimitPercent, "50", unplaced},
} {
t.Run(tc.setting, func(t *testing.T) {
@@ -96,8 +100,8 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
for _, tc := range []struct {
setting, value, from string
}{
{asnLimitPercent, asnDEHalf, fromDE},
{countryLimitPercent, "DE:50", fromDE},
{tagLimitPercent, asnTagDEHalf, fromDE},
{tagLimitPercent, "Country=DE:50", fromDE},
{unknownLimitPercent, "50", unplaced},
{asnBytesPercent, "as64496:50", fromDE},
{countryBytesPercent, countryDEHalf, fromDE},
@@ -151,13 +155,13 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
},
{
"raising the byte limits back",
map[string]string{asnLimitPercent: asnDEHalf, asnBytesPercent: asnDE + ":100"},
"50 from " + asnLimitPercent, none, "",
map[string]string{tagLimitPercent: asnTagDEHalf, asnBytesPercent: asnDE + ":100"},
"50 from " + tagLimitPercent, none, "",
},
{
"raising the byte limits back, by country",
map[string]string{countryLimitPercent: countryDEHalf, countryBytesPercent: "de:100"},
"50 from " + countryLimitPercent, none, "",
map[string]string{tagLimitPercent: countryTagDEHalf, countryBytesPercent: "de:100"},
"50 from " + tagLimitPercent, none, "",
},
} {
t.Run(tc.name, func(t *testing.T) {
@@ -184,19 +188,19 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
func TestZeroPercentIsAZeroAllowance(t *testing.T) {
t.Parallel()
s, _, _ := startWithLookups(t, map[string]string{asnLimitPercent: asnDE + ":0"})
s, _, _ := startWithLookups(t, map[string]string{unknownLimitPercent: "0"})
// The first request breaks the limit, and bans the client; the log line
// gives the 0.
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
line := s.get(unplaced, http.StatusForbidden, requestlog.ActionRateLimited)
if line.fields["limit_percent"] != float64(0) ||
line.fields["limit_percent_setting"] != asnLimitPercent {
line.fields["limit_percent_setting"] != unknownLimitPercent {
t.Errorf("log line has limit_percent %v from %v, want 0 from %s",
line.fields["limit_percent"], line.fields["limit_percent_setting"],
asnLimitPercent)
unknownLimitPercent)
}
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
s.get(unplaced, http.StatusForbidden, requestlog.ActionBanned)
}
func TestLowestPercentageApplies(t *testing.T) {
@@ -211,29 +215,29 @@ func TestLowestPercentageApplies(t *testing.T) {
}{
{
"the country's",
map[string]string{asnLimitPercent: asnDEHalf, countryLimitPercent: "de:25"},
fromDE, "25 from " + countryLimitPercent,
map[string]string{tagLimitPercent: asnTagDEHalf + "," + countryTagDEQuarter},
fromDE, "25 from " + tagLimitPercent,
},
{
"the AS number's",
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: countryDEHalf},
fromDE, "25 from " + asnLimitPercent,
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEHalf},
fromDE, "25 from " + tagLimitPercent,
},
{
"the AS number's, the first of two alike",
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: "de:25"},
fromDE, "25 from " + asnLimitPercent,
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEQuarter},
fromDE, "25 from " + tagLimitPercent,
},
{
"that for a client without a country",
map[string]string{asnLimitPercent: "AS64500:50", unknownLimitPercent: "25"},
map[string]string{tagLimitPercent: "asn=64500:50", unknownLimitPercent: "25"},
noCountry, "25 from " + unknownLimitPercent,
},
{
// SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
"the AS number's, for a client without a country",
map[string]string{asnLimitPercent: "AS64500:25"},
noCountry, "25 from " + asnLimitPercent,
map[string]string{tagLimitPercent: "asn=64500:25"},
noCountry, "25 from " + tagLimitPercent,
},
} {
t.Run(tc.name, func(t *testing.T) {
@@ -304,8 +308,12 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
setting, value string
waits bool
}{
{asnLimitPercent, asnDEHalf, true},
{countryLimitPercent, countryDEHalf, true},
{tagLimitPercent, asnTagDEHalf, true},
{tagLimitPercent, countryTagDEHalf, true},
{tagDeny, denyKP, true},
// A tag the lookup does not give needs no answer.
{tagLimitPercent, "vpn:50", false},
{tagDeny, "vpn", false},
{asnBytesPercent, asnDEHalf, true},
{countryBytesPercent, countryDEHalf, true},
{unknownLimitPercent, "99", true},
@@ -363,15 +371,15 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
{
// A quarter of 12 requests a minute is 3: the fourth breaks it.
"a rate limit",
map[string]string{rateLimitPerMinute: "12", asnLimitPercent: asnDEQuarter},
map[string]string{rateLimitPerMinute: "12", tagLimitPercent: asnTagDEQuarter},
3, http.StatusForbidden, requestlog.ActionRateLimited,
"requests per minute over the limit of 3", "25 from " + asnLimitPercent,
"requests per minute over the limit of 3", "25 from " + tagLimitPercent,
},
{
// The byte limits' percentage, not the rate limits'.
"a byte limit",
map[string]string{
bytesLimitPerMinute: twoUploads, asnLimitPercent: asnDEQuarter,
bytesLimitPerMinute: twoUploads, tagLimitPercent: asnTagDEQuarter,
asnBytesPercent: asnDEHalf,
},
0, http.StatusOK, requestlog.ActionForward,
@@ -487,7 +495,7 @@ func wantPercent(t *testing.T, what string, percent *int64, setting, want string
}
// percentText gives a limit percentage and the setting that gave it as
// text, such as "50 from SWW_ASN_LIMIT_PERCENT", or none when both are
// text, such as "50 from SWW_TAG_LIMIT_PERCENT", or none when both are
// left out.
func percentText(percent *int64, setting string) string {
switch {
+5 -3
View File
@@ -17,9 +17,10 @@ import (
// SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as
// pathExempt decides, and notes the rules it matched and its score in the
// log line, and the rules in the metrics. A score at or over
// SWW_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert,
// and in block mode refuses the request with 403, which is an offence its
// client's history counts. Its figures are the score and the threshold.
// SWW_WAF_ANOMALY_THRESHOLD is a match: it adds the tag exploitsignature,
// raises the waf_block alert, and in block mode refuses the request with 403,
// which is an offence its client's history counts. Its figures are the score
// and the threshold.
type coreRuleSet struct{}
func (coreRuleSet) name() string { return "core_rule_set" }
@@ -50,6 +51,7 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
return nil
}
rq.addTag(config.ExploitSignatureTag)
rq.alertWAFBlock(result)
if cfg.WAFMode == config.WAFModeDetect {
@@ -61,6 +61,41 @@ func wantWAF(t *testing.T, line logLine, score *int, ruleIDs ...int) {
}
}
func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
mode string
status int
action string
}{
{block, block, http.StatusForbidden, requestlog.ActionWAFBlocked},
{detect, detect, http.StatusOK, requestlog.ActionForward},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
wantTags(t, s.request(client, sqlInjection, tc.status, tc.action),
"exploitsignature")
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
})
}
}
func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
wafMode: detect, tagDeny: "exploitsignature",
})
line := s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
}
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) {
t.Parallel()
+10 -17
View File
@@ -1,7 +1,6 @@
package discriminator
// The heuristic countries. It reads SWW_DENIED_COUNTRIES and
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
// The heuristic countries. It reads SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
import (
"context"
@@ -11,32 +10,26 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// countries refuses a request by the client's country as it was looked up. A
// client without a country, or whose country cannot be found, is refused only
// by SWW_EXCLUSIVELY_ALLOWED_COUNTRIES. Its figures are the country and the
// setting that refused it.
// countries refuses a request when SWW_EXCLUSIVELY_ALLOWED_COUNTRIES is set and
// does not hold the client's country as it was looked up. A client without a
// country, or whose country cannot be found, is refused. Its figures are the
// country and the setting that refused it.
type countries struct{}
func (countries) name() string { return "countries" }
func (countries) check(_ context.Context, rq *request, _ time.Time) *verdict {
denied := rq.h.config.DeniedCountries
allowed := rq.h.config.ExclusivelyAllowedCountries
country := rq.line.Country
var setting string
switch {
case slices.Contains(denied, country):
setting = "SWW_DENIED_COUNTRIES"
case len(allowed) > 0 && !slices.Contains(allowed, country):
setting = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
default:
if len(allowed) == 0 || slices.Contains(allowed, country) {
return nil
}
return &verdict{
action: requestlog.ActionCountryDenied,
figures: map[string]any{"country": country, "setting": setting},
action: requestlog.ActionCountryDenied,
figures: map[string]any{
"country": country, "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
},
}
}
+8 -8
View File
@@ -33,13 +33,13 @@ func TestCountryLists(t *testing.T) {
env map[string]string
refused []string
}{
{"denied", map[string]string{deniedCountries: "kp"}, []string{fromKP}},
{
"exclusively allowed", map[string]string{allowedCountries: "DE"},
[]string{fromKP, unplaced},
},
{
"both", map[string]string{deniedCountries: "kp", allowedCountries: "de,fr"},
"with a country in SWW_TAG_DENY",
map[string]string{tagDeny: denyKP, allowedCountries: "de,fr"},
[]string{fromKP, unplaced},
},
} {
@@ -95,12 +95,12 @@ func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
geojsURL, _ := startGeoJS(t)
s, _, _ := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h", deniedCountries: "kp", allowedCountries: "de",
lookupTimeout: "1h", allowedCountries: "de",
})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)
wantHeuristic(t, line, "countries",
`{"country": "KP", "setting": "SWW_DENIED_COUNTRIES"}`)
`{"country": "KP", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied)
wantHeuristic(t, line, "countries",
@@ -117,9 +117,9 @@ func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
})
geojsURL, _ := startGeoJS(t)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
deniedCountries: "kp",
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
allowedCountries: "de",
})
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
@@ -202,7 +202,7 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
env map[string]string
}{
{"no setting needs the lookup", nil},
{"a country list is set", map[string]string{deniedCountries: "kp"}},
{"a tag setting needs it", map[string]string{tagDeny: denyKP}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
+4 -3
View File
@@ -13,9 +13,9 @@ import (
)
// crowdSec refuses a request when a decision of the CrowdSec decision list on
// the client is in force at now. It then notes the list, as noteHit does, and
// bans the client until that decision ends. Its figures are the decision's
// scenario and when it ends.
// the client is in force at now. It then adds the tag crowdsec, notes the
// list, as noteHit does, and bans the client until that decision ends. Its
// figures are the decision's scenario and when it ends.
type crowdSec struct{}
func (crowdSec) name() string { return "crowdsec" }
@@ -26,6 +26,7 @@ func (crowdSec) check(_ context.Context, rq *request, now time.Time) *verdict {
return nil
}
rq.addTag("crowdsec")
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
"listed by the CrowdSec decision list")
rq.banForCrowdSec(now, decision)
+1
View File
@@ -108,6 +108,7 @@ func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) {
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
wantHeuristic(t, line, "crowdsec", `{"scenario": "crowdsecurity/ssh-bf", `+
`"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`)
wantTags(t, line, "crowdsec")
}
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
+2 -2
View File
@@ -414,7 +414,7 @@ func TestReportNeverReachesAQuestionStillBeingHandled(t *testing.T) {
geojsURL, asked, release := startHeldGeoJS(t)
s := startDiscriminator(t, geojsURL, map[string]string{
lookupTimeout: "1h", deniedCountries: "kp", bytesLimitPerMinute: byteLimit,
lookupTimeout: "1h", tagDeny: denyKP, bytesLimitPerMinute: byteLimit,
})
first := s.question(siteA, client, "/")
@@ -765,7 +765,7 @@ func TestAllowCarriesTheLookupHeadersWhileTheSettingIsSet(t *testing.T) {
geojsURL, _ := startGeoJS(t)
s := startDiscriminator(t, geojsURL, map[string]string{
addLookupHeaders: tc.setting, lookupTimeout: "1h", deniedCountries: "kp",
addLookupHeaders: tc.setting, lookupTimeout: "1h", tagDeny: denyKP,
})
got := s.ask(fromDE, "/")
+6 -1
View File
@@ -11,7 +11,8 @@ import (
)
// dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed
// does, and refuses the request when SWW_REPUTATION_ACTION is deny. Being
// does, adds the tag dnsbl=<zone> for each, the zone's key masked, and refuses
// the request when SWW_REPUTATION_ACTION is deny. 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
@@ -25,6 +26,10 @@ func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
rq.dnsblListed = len(listedBy) > 0
rq.noteListed(listedBy, "listed by a DNSBL zone")
for _, zone := range listedBy {
rq.addTag("dnsbl=" + zone)
}
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
return nil
}
+5
View File
@@ -49,18 +49,23 @@ func beforeLookup() []heuristic {
}
// afterLookup are the heuristics that run once the lookup has been made.
// tag_deny runs twice: before the rate limits, on the tags known by then, and
// last, on the tag rule_files and core_rule_set can add.
func afterLookup() []heuristic {
return []heuristic{
tagLists{},
countries{},
blocklists{},
crowdSec{},
dnsbl{},
abuseIPDB{},
tagDeny{},
rateLimits{},
trapPaths{},
ruleFiles{},
headerLimits{},
coreRuleSet{},
tagDeny{},
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
s, clk, server := startWithClock(t, geojsURL, map[string]string{
lookupTimeout: "1h",
rateLimitPerMinute: "2",
deniedCountries: "kp",
tagDeny: denyKP,
})
start := clk.Now()
+31 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"net/netip"
"strings"
"time"
"sneak.berlin/go/smallwebwaf/internal/lookup"
@@ -19,7 +20,8 @@ const (
)
// lookUp looks up the client's AS number and country, in the lookup
// database or through GeoJS, and notes them for the log line, unless
// database or through GeoJS, and notes them for the log line and as the
// request's asn= and country= tags, unless
// SWW_LOOKUP_SOURCE is off or the client is on a private, loopback or
// link-local address, which no lookup can place. The lookup database
// answers at once. With GeoJS, while a setting needs the answer, such as a
@@ -47,6 +49,34 @@ func (rq *request) lookUp(ctx context.Context, now time.Time) {
rq.line.ASN = rq.lookupAnswer.ASN
rq.line.ASName = rq.lookupAnswer.ASName
rq.line.Country = rq.lookupAnswer.Country
if tag := asnTag(rq.line.ASN); tag != "" {
rq.addTag(tag)
}
if tag := countryTag(rq.line.Country); tag != "" {
rq.addTag(tag)
}
}
// asnTag returns the tag of asn, an AS number as the lookup gives it, such as
// asn=64496 for AS64496, or "" for no AS number.
func asnTag(asn string) string {
if asn == "" {
return ""
}
return "asn=" + strings.TrimPrefix(asn, "AS")
}
// countryTag returns the tag of country, a code as the lookup gives it, such
// as country=de for DE, or "" for no country.
func countryTag(country string) string {
if country == "" {
return ""
}
return "country=" + strings.ToLower(country)
}
// addLookup adds answer, an answer about a client from the lookup
+3 -3
View File
@@ -94,9 +94,9 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 58, 0, time.UTC)}
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
map[string]string{
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
deniedCountries: "kp",
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
tagDeny: denyKP,
})
requestFromDE := func() *http.Request {
+9 -9
View File
@@ -195,10 +195,10 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
_, _ = io.WriteString(w, "hello")
})
env := map[string]string{
trustedProxies: trustLocalhost,
metricsToken: token,
metricsTopN: "2",
deniedCountries: "kp",
trustedProxies: trustLocalhost,
metricsToken: token,
metricsTopN: "2",
allowedCountries: "de,fr",
}
geojsURL, _ := startGeoJS(t)
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, time.Now, env)
@@ -322,13 +322,13 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
addr, _, server := startProxyWithClock(t, app.URL, geojs.URL, time.Now,
map[string]string{
trustedProxies: trustLocalhost,
metricsToken: token,
deniedCountries: "kp",
trustedProxies: trustLocalhost,
metricsToken: token,
tagDeny: denyKP,
})
// GeoJS fails, so the client counts as coming from an unknown country,
// which SWW_DENIED_COUNTRIES does not refuse.
// GeoJS fails, so the client has no country= tag, and SWW_TAG_DENY does
// not refuse it.
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Header.Set(forwardedFor, fromDE)
wantStatus(t, do(t, req), http.StatusOK)
+2 -2
View File
@@ -50,7 +50,7 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
lookupTimeout: "1h",
rateLimitPerMinute: "1",
denyNets: denied,
deniedCountries: "kp",
tagDeny: denyKP,
}
if tc.setting != "" {
@@ -71,7 +71,7 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
for _, sent := range []struct{ from, refusal string }{
{denied, requestlog.ActionDenied},
{banned, requestlog.ActionBanned},
{fromKP, requestlog.ActionCountryDenied},
{fromKP, requestlog.ActionDenied},
{fromDE, requestlog.ActionRateLimited},
} {
if !tc.observe {
+7 -5
View File
@@ -155,11 +155,12 @@ func newHandler(params Params) *handler {
h.geojs = lookup.New(lookup.Params{
URL: params.GeoJSURL,
Timeout: params.Config.LookupTimeout,
// The country lists, the headers and the biased thresholds act on
// the answer before the request goes on.
Wait: len(params.Config.DeniedCountries) > 0 ||
len(params.Config.ExclusivelyAllowedCountries) > 0 ||
params.Config.AddLookupHeaders || biasedThresholdsSet(params.Config),
// The country list, the headers, the tag settings that name an asn=
// or country= tag and the biased thresholds act on the answer before
// the request goes on.
Wait: len(params.Config.ExclusivelyAllowedCountries) > 0 ||
params.Config.AddLookupHeaders || params.Config.TagsNeedLookup() ||
biasedThresholdsSet(params.Config),
Answered: h.addLookup,
Now: params.Now,
ProcessLog: params.ProcessLog,
@@ -183,6 +184,7 @@ func newReputation(
lists := reputation.New(reputation.Params{
BlocklistURLs: cfg.BlocklistURLs, Refresh: cfg.BlocklistRefresh,
ASNLimitPercentURL: cfg.ASNLimitPercentURL,
TagLists: cfg.TagLists,
CrowdSecDecisionsURL: cfg.CrowdSecDecisionsURL, CrowdSecKey: cfg.CrowdSecKey,
Now: params.Now, ProcessLog: params.ProcessLog, Alerts: params.Alerts,
})
+3 -1
View File
@@ -69,7 +69,9 @@ const (
lookupDBPath = "SWW_LOOKUP_DB_PATH"
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
deniedCountries = "SWW_DENIED_COUNTRIES"
tagDeny = "SWW_TAG_DENY"
// denyKP is the value of SWW_TAG_DENY that refuses fromKP's country.
denyKP = "country=kp"
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
banResponse = "SWW_BAN_RESPONSE"
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
+2 -2
View File
@@ -136,7 +136,7 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
rateLimitPerMinute: "1",
rateLimitExemptPaths: "/assets/,/favicon.ico",
denyNets: denied,
deniedCountries: "kp",
tagDeny: denyKP,
})
// The answers are kept before the requests, so that none waits for
@@ -167,7 +167,7 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
s.request(fromKP, "/assets/app.js",
http.StatusForbidden, requestlog.ActionCountryDenied)
http.StatusForbidden, requestlog.ActionDenied)
}
func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
+1 -1
View File
@@ -191,7 +191,7 @@ func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
lookupTimeout: "1h",
errorBurstThreshold: "2",
rulesDir: writeRules(t, testRules),
countryLimitPercent: countryDEHalf,
tagLimitPercent: countryTagDEHalf,
})
// Half of the threshold would be one, which the second refusal is over.
+50 -10
View File
@@ -172,7 +172,7 @@ func TestBlocklistsComeAfterTheCountryListsAndSkipAllowNets(t *testing.T) {
t.Parallel()
s, server, queue := startWithLookups(t, map[string]string{
blocklistURLs: dropURL, deniedCountries: "kp", allowNets: fromDE,
blocklistURLs: dropURL, allowedCountries: "de", allowNets: fromDE,
})
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
@@ -211,18 +211,18 @@ func TestBlocklistLimitTakesPartInTheLowestPercentageOfEveryLimit(t *testing.T)
// percentText gives them, and limitHit its limit_hit.
want, limitHit string
}{
{limitHalf, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes},
{limitQuarter, asnDEHalf, "25 from " + blocklistAction, minuteBytes},
{limitHalf, asnTagDEQuarter, "25 from " + tagLimitPercent, minuteBytes},
{limitQuarter, asnTagDEHalf, "25 from " + blocklistAction, minuteBytes},
// The AS number's, the first of two alike.
{limitQuarter, asnDEQuarter, "25 from " + asnLimitPercent, minuteBytes},
{actionLog, asnDE + ":100", none, ""},
{limitQuarter, asnTagDEQuarter, "25 from " + tagLimitPercent, minuteBytes},
{actionLog, "asn=64511:25", none, ""},
} {
t.Run(tc.action+" "+tc.asnPercent, func(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
bytesLimitPerMinute: twoUploads, blocklistURLs: dropURL,
blocklistAction: tc.action, asnLimitPercent: tc.asnPercent,
blocklistAction: tc.action, tagLimitPercent: tc.asnPercent,
})
loadLists(t, server, map[string][]string{dropURL: {fromDE}})
@@ -246,7 +246,7 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
const (
fromURL = "25 from " + asnLimitPercentURL
fromSetting = "25 from " + asnLimitPercent
fromSetting = "25 from " + tagLimitPercent
)
for _, tc := range []struct {
@@ -260,16 +260,16 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
{"the file's alone", nil, asnDEQuarter, fromURL, fromURL},
{
"the file's, lower than the setting's",
map[string]string{asnLimitPercent: asnDEHalf}, asnDEQuarter, fromURL, fromURL,
map[string]string{tagLimitPercent: asnTagDEHalf}, asnDEQuarter, fromURL, fromURL,
},
{
"the setting's, lower than the file's",
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEHalf,
map[string]string{tagLimitPercent: asnTagDEQuarter}, asnDEHalf,
fromSetting, fromSetting,
},
{
"the setting's, the first of two alike",
map[string]string{asnLimitPercent: asnDEQuarter}, asnDEQuarter,
map[string]string{tagLimitPercent: asnTagDEQuarter}, asnDEQuarter,
fromSetting, fromSetting,
},
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
@@ -482,6 +482,46 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
}
}
func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
env map[string]string
load func(*discriminator.Server)
want []string
}{
{
"dnsbl",
map[string]string{
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionLog,
},
func(server *discriminator.Server) {
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
},
[]string{"asn=64496", "country=de", "dnsbl=" + dnsblZone},
},
{
abuseipdb,
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionLog},
func(server *discriminator.Server) {
loadScores(server, map[string]int64{fromDE: 80})
},
[]string{abuseipdb, "asn=64496", "country=de"},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, tc.env)
tc.load(server)
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
tc.want...)
})
}
}
func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(t *testing.T) {
t.Parallel()
+11
View File
@@ -138,6 +138,17 @@ func (h *handler) startRequest(
return rq
}
// addTag adds tag, in lower case, to the request's tags, which stay sorted and
// hold each tag once.
func (rq *request) addTag(tag string) {
tag = strings.ToLower(tag)
at, found := slices.BinarySearch(rq.line.Tags, tag)
if !found {
rq.line.Tags = slices.Insert(rq.line.Tags, at, tag)
}
}
// requestHeaders returns the headers of r that names lists, by name in
// lower case, each with its values joined by ", ". Authorization, Cookie
// and Set-Cookie are never among them, whatever names says.
+5 -1
View File
@@ -8,12 +8,14 @@ import (
"time"
"sneak.berlin/go/smallwebwaf/internal/bans"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// ruleFiles checks the request against the rules of the rule files at now,
// notes the ids of those it matches in the log line, and refuses the request
// notes the ids of those it matches in the log line, adds the tag
// exploitsignature when it matches any, and refuses the request
// when the last rule it matches refuses it: with ActionRuleBlocked for a block
// rule, and ActionBanned for a ban rule. A ban rule bans the client's netblock
// for a clear sign of attack, or in observe mode raises the alert for the ban
@@ -35,6 +37,8 @@ func (ruleFiles) check(_ context.Context, rq *request, now time.Time) *verdict {
return nil
}
rq.addTag(config.ExploitSignatureTag)
// Only the last rule matched can refuse the request.
last := matched[len(matched)-1]
figures := map[string]any{"rule_id": last.ID, "rule_action": last.Action}
+14
View File
@@ -93,6 +93,20 @@ func TestEachRuleAction(t *testing.T) {
s.get(client, http.StatusTooManyRequests, requestlog.ActionBanned)
}
func TestEachRuleActionAddsTheExploitSignatureTag(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/noted$\n"+testRules),
})
wantTags(t, s.request(client, "/noted", http.StatusOK, requestlog.ActionForward),
"exploitsignature")
wantTags(t, s.request(client, "/blocked", http.StatusForbidden,
requestlog.ActionRuleBlocked), "exploitsignature")
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
}
func TestRuleRefusalNamesItsHeuristicAndTheRule(t *testing.T) {
t.Parallel()
+6 -6
View File
@@ -32,7 +32,7 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) {
trustedProxies: trustLocalhost,
allowNets: "198.51.100.0/24",
denyNets: fromKP,
deniedCountries: "kp",
tagDeny: denyKP,
rateLimitPerMinute: "1",
})
@@ -83,9 +83,9 @@ func TestDenyNetsRefuseBeforeTheLookup(t *testing.T) {
})
geojsURL, asked := startGeoJS(t)
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
trustedProxies: trustLocalhost,
denyNets: "203.0.113.0/24",
deniedCountries: "kp",
trustedProxies: trustLocalhost,
denyNets: "203.0.113.0/24",
tagDeny: denyKP,
})
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
@@ -142,7 +142,7 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
trustedProxies: trustLocalhost,
lookupTimeout: "1h",
rateLimitExemptNets: listedAddr + "," + fromKP,
deniedCountries: "kp",
tagDeny: denyKP,
rateLimitPerMinute: "1",
})
@@ -154,7 +154,7 @@ func TestRateLimitExemptNetsAreNeitherCountedNorRefused(t *testing.T) {
{listedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusOK, requestlog.ActionForward},
{unlistedAddr, http.StatusForbidden, requestlog.ActionRateLimited},
{fromKP, http.StatusForbidden, requestlog.ActionCountryDenied},
{fromKP, http.StatusForbidden, requestlog.ActionDenied},
})
}
+30
View File
@@ -0,0 +1,30 @@
package discriminator
// The heuristic tag_deny. It reads SWW_TAG_DENY.
import (
"context"
"slices"
"time"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// tagDeny refuses a request that carries a tag in SWW_TAG_DENY. Its figure is
// the first such tag, in sorted order.
type tagDeny struct{}
func (tagDeny) name() string { return "tag_deny" }
func (tagDeny) check(_ context.Context, rq *request, _ time.Time) *verdict {
for _, tag := range rq.line.Tags {
if slices.Contains(rq.h.config.TagDeny, tag) {
return &verdict{
action: requestlog.ActionDenied,
figures: map[string]any{"tag": tag},
}
}
}
return nil
}
+75
View File
@@ -0,0 +1,75 @@
package discriminator_test
import (
"net/http"
"testing"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
func TestTagDenyRefusesALookupTagAndNamesTheTag(t *testing.T) {
t.Parallel()
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=KP"})
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "country=kp"}`)
wantTags(t, line, "asn=64511", "country=kp")
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
}
func TestTagDenyRefusesAnASNTag(t *testing.T) {
t.Parallel()
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "asn=64511"})
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
"tag_deny", `{"tag": "asn=64511"}`)
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
}
func TestTagDenyRefusesAListTagBeforeTheRateLimitsCount(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
tagLists: "torexit=" + torList, tagDeny: "torexit", rateLimitPerMinute: "1",
})
loadLists(t, server, map[string][]string{torList: {fromDE}})
// Each is refused for its tag: none is counted to break the limit of one.
for range 3 {
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionDenied),
"tag_deny", `{"tag": "torexit"}`)
}
}
func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
t.Parallel()
s, _, _ := startWithClock(t, "", map[string]string{
rulesDir: writeRules(t, "noted path log ^/\n"), tagDeny: "exploitsignature",
})
line := s.get(client, http.StatusForbidden, requestlog.ActionDenied)
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
wantTags(t, line, "exploitsignature")
}
func TestTagLimitPercentLowersALimitTheLowestWinning(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
rateLimitPerMinute: fourAMinute,
tagLists: "vpn=" + vpnList + ",tor=" + torList,
tagLimitPercent: "vpn:50,tor:25",
})
loadLists(t, server, map[string][]string{vpnList: {fromDE}, torList: {fromDE}})
// A quarter of 4 requests a minute: the second breaks the limit.
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
"25 from "+tagLimitPercent)
}
+24
View File
@@ -0,0 +1,24 @@
package discriminator
// The heuristic tag_lists. It reads the address lists of SWW_TAG_LISTS through
// the reputation lists.
import (
"context"
"time"
)
// tagLists adds to the request one tag for each list of SWW_TAG_LISTS that
// holds the client, the tag being the list's name. It refuses nothing, and runs
// first so that every refusal's log line carries these tags.
type tagLists struct{}
func (tagLists) name() string { return "tag_lists" }
func (tagLists) check(_ context.Context, rq *request, _ time.Time) *verdict {
for _, name := range rq.h.lists.TagsOf(rq.client) {
rq.addTag(name)
}
return nil
}
+73
View File
@@ -0,0 +1,73 @@
package discriminator_test
import (
"net/http"
"slices"
"testing"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
)
// tagLists is SWW_TAG_LISTS, and vpnList and torList lists it names.
const (
tagLists = "SWW_TAG_LISTS"
vpnList = "https://lists.example/vpn.txt"
torList = "https://lists.example/torexit.txt"
)
// wantTags checks the log line's tags.
func wantTags(t *testing.T, line logLine, want ...string) {
t.Helper()
got, _ := line.fields["tags"].([]any)
tags := make([]string, 0, len(got))
for _, tag := range got {
text, _ := tag.(string)
tags = append(tags, text)
}
if !slices.Equal(tags, want) {
t.Errorf("log line has tags %v, want %v", tags, want)
}
}
func TestLookupAndTagListsGiveTheRequestItsTagsSorted(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
tagLists: "vpn=mullvad=" + vpnList + ",TorExit=" + torList,
})
loadLists(t, server, map[string][]string{
vpnList: {fromDE}, torList: {fromDE + "/32", fromKP},
})
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
"asn=64496", "country=de", "torexit", "vpn=mullvad")
wantTags(t, s.get(fromKP, http.StatusOK, requestlog.ActionForward),
"asn=64511", "country=kp", "torexit")
}
func TestRequestWithoutATagLeavesTheFieldOut(t *testing.T) {
t.Parallel()
// With no lookup, a client is not placed and no setting gives it a tag.
s, _, _ := startWithClock(t, "", map[string]string{lookupSource: "off"})
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
if _, present := line.fields["tags"]; present {
t.Errorf("log line has tags %v, want none", line.fields["tags"])
}
}
func TestTagListsTagEvenARequestTheCountryListRefuses(t *testing.T) {
t.Parallel()
s, server, _ := startWithLookups(t, map[string]string{
tagLists: "vpn=" + vpnList, allowedCountries: "de",
})
loadLists(t, server, map[string][]string{vpnList: {fromKP}})
wantTags(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
"asn=64511", "country=kp", "vpn")
}
+2 -2
View File
@@ -25,7 +25,7 @@ type busiest struct {
requests *prometheus.CounterVec
requestBytes *prometheus.CounterVec
responseBytes *prometheus.CounterVec
// refused are, by country, the requests the country lists refused; nil
// refused are, by country, the requests the country list refused; nil
// by AS number.
refused *prometheus.CounterVec
@@ -42,7 +42,7 @@ type busiest struct {
func newCountries(topN int) *busiest {
countries := newBusiest(topN, "country", "the client's country")
countries.refused = counterVec("sww_country_list_refusals_total",
"Requests the country lists refused, by the client's country.",
"Requests SWW_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
[]string{"country"})
return countries
+28 -5
View File
@@ -1,7 +1,8 @@
// Package reputation fetches the lists the settings name by URL: the
// blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines
// SWW_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec
// engine SWW_CROWDSEC_LAPI_URL names. It keeps the last good copy of
// SWW_ASN_LIMIT_PERCENT_URL names, the address lists of SWW_TAG_LISTS, and
// the decision list of the CrowdSec engine SWW_CROWDSEC_LAPI_URL names. It
// keeps the last good copy of
// each, whole, comment lines included, which is used while a fetch fails,
// and when each was last tried. It also asks the DNSBL zones of
// SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks
@@ -71,6 +72,8 @@ type Params struct {
// (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset.
BlocklistURLs []string
ASNLimitPercentURL string
// TagLists are the address lists that add a tag (SWW_TAG_LISTS).
TagLists []config.TagList
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
// SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
// (SWW_CROWDSEC_LAPI_KEY).
@@ -152,14 +155,18 @@ func New(params Params) *Lists {
}
// URLs returns the URL of every list: the blocklists' in the order
// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then
// the CrowdSec decision list's.
// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then the
// tag lists', then the CrowdSec decision list's.
func (l *Lists) URLs() []string {
urls := slices.Clone(l.params.BlocklistURLs)
if l.params.ASNLimitPercentURL != "" {
urls = append(urls, l.params.ASNLimitPercentURL)
}
for _, list := range l.params.TagLists {
urls = append(urls, list.URL)
}
if l.params.CrowdSecDecisionsURL != "" {
urls = append(urls, l.params.CrowdSecDecisionsURL)
}
@@ -184,6 +191,22 @@ func (l *Lists) ListedBy(addr netip.Addr) []string {
return listedBy
}
// TagsOf returns the names of the tag lists whose copy holds addr.
func (l *Lists) TagsOf(addr netip.Addr) []string {
l.mu.Lock()
defer l.mu.Unlock()
var names []string
for _, list := range l.params.TagLists {
if l.lists[list.URL].entries.contain(addr) {
names = append(names, list.Name)
}
}
return names
}
// ASNLimitPercent returns the percentage the copy of the file of
// AS:percent lines gives asn, and whether it lists asn.
func (l *Lists) ASNLimitPercent(asn string) (int64, bool) {
@@ -551,7 +574,7 @@ func parseNetblock(text string) (netip.Prefix, bool) {
}
// parsePercents reads the lines of the file of AS:percent lines, each an
// AS number, : and a percentage, as SWW_ASN_LIMIT_PERCENT takes them. An
// AS number, : and a percentage, such as AS64496:50. An
// AS number listed more than once gets the lowest of its percentages.
func parsePercents(lines []string) (entries, error) {
found := entries{percents: map[string]int64{}}
+5 -2
View File
@@ -38,9 +38,10 @@ const (
// ActionDenied is a request refused because its client is in
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
// SWW_REPUTATION_ACTION is deny.
// SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY.
ActionDenied = "denied"
// ActionCountryDenied is a request refused for its client's country.
// ActionCountryDenied is a request refused because its client's country
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
ActionCountryDenied = "country_denied"
// ActionAdmin is a request discriminator answered at one of its own
// endpoints, or a question about a path under /_sww/.
@@ -172,6 +173,8 @@ type Line struct {
// whose verdict lists it, their keys masked, then abuseipdb when its
// score is a hit.
Reputation []string `json:"reputation,omitempty"`
// Tags are the request's tags, sorted.
Tags []string `json:"tags,omitempty"`
// Offence is the offence the request was held as, OffenceLimit.
Offence string `json:"offence,omitempty"`
// BanExpires is when the ban the request made, or was refused under,
+9 -9
View File
@@ -611,14 +611,14 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
writeLookupDatabase(t, path, "DE")
env := map[string]string{
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
metricsToken: token,
instanceName: instance,
lookupSource: "file",
lookupDBPath: path,
"SWW_DENIED_COUNTRIES": "kp",
listenAddr: localhost + ":0",
stateDir: t.TempDir(),
rulesDir: t.TempDir(),
metricsToken: token,
instanceName: instance,
lookupSource: "file",
lookupDBPath: path,
"SWW_TAG_DENY": "country=kp",
// The requests sent until a replacement takes effect, and those
// for the metrics, must not break a rate limit, whose ban would
// refuse them too.
@@ -1280,7 +1280,7 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
"SWW_RATE_LIMIT_PER_HOUR": "1500",
rateLimitPerDay: "6000",
"SWW_RATE_LIMIT_EXEMPT_PATHS": "",
"SWW_DENIED_COUNTRIES": "",
"SWW_TAG_DENY": "",
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
"SWW_BAN_RESPONSE": "403",
"SWW_LIMIT_BAN_DURATION": "1h",