Request tags, with refusing and limits per tag (closes #175)
check / check (push) Successful in 3m36s
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:
1 parent
2349b2a081
commit
60630859af
42 files changed
+1088
-319
No files matched your search
+243
-26
@@ -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
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, "/")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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{}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user