Compare commits

..
Author SHA1 Message Date
clawbot 4d4ae61abf Rename the settings whose names do not say what they control (closes #234)
check / check (push) Waiting to run
Applies the setting renames of the naming audit under the SWW_WAF_ and
SWW_RPROXY_ prefixes, with sneak's names for the IP reputation cache
time and the two time limits, which now take milliseconds. The rproxy
log field default_disposition becomes waf_backend_no_answer, and
config.Config and config.FromEnvironment become config.WAF and
config.WAFFromEnvironment. Go fields, test names and prose follow. No
aliases; nothing reads the old names.

Model: opus-5-5
2026-11-12 09:48:09 +00:00
clawbot 3304beca46 rproxy: read the rest of a Connection: close visitor's body after an early answer (closes #373)
check / check (push) Waiting to run
Go's server reads up to 256 KiB of a body left unread once the handler
returns, but not a body with a declared length from a visitor that sent
Connection: close. It closes that connection with the rest unread, and
the visitor, still sending, gets a reset. rproxy now reads that rest
itself, until the deadline already set on the connection, through a
MaxBytesReader made with Go's own ResponseWriter. With more than 256 KiB
left, that has Go's server tell the visitor it sends no more and wait
500 ms before it closes. rproxy reads the rest only after an answer with
a Content-Length, because Go's server ends any other answer only once
the handler returns.

Model: opus-5-5
2026-11-12 10:43:29 +01:00
12 changed files with 267 additions and 106 deletions

No files matched your search

+3 -3
View File
@@ -214,9 +214,9 @@ field gives that name. `refusal_limit` and `byte_limits` give theirs in
are updated, and the alert thresholds are evaluated. are updated, and the alert thresholds are evaluated.
For a request it refused, `waf` adds nothing to the byte limits. It updates For a request it refused, `waf` adds nothing to the byte limits. It updates
the history and the anomaly counters at its decision. It counts the refusals the history and the anomaly counters at its decision. A refusal for a rule
the refusal limit counts at the decision too (`refusal_limit`; see "Attack file match, a trap path or a Core Rule Set match also counts toward the
detection"). refusal limit at the decision (`refusal_limit`; see "Attack detection").
In `SWW_WAF_MODE=observe`, every refusal above is logged as what would have In `SWW_WAF_MODE=observe`, every refusal above is logged as what would have
happened, nothing is refused, and `waf` makes no ban of its own. A path under happened, nothing is refused, and `waf` makes no ban of its own. A path under
+4 -5
View File
@@ -65,11 +65,10 @@ func Command(v *viper.Viper) (string, error) {
} }
} }
// environment is where WAFFromEnvironment and RProxyFromEnvironment read // environment is where WAFFromEnvironment and RProxyFromEnvironment read the
// the settings: it notes each // settings: it notes each value for the log, and keeps the first error. It
// value for the log, and keeps the first error. It reads every setting // reads every setting through v, the viper NewViper returns, and honours
// through v, the viper NewViper returns, and honours each setting's _FILE // each setting's _FILE form too.
// form too.
type environment struct { type environment struct {
v *viper.Viper v *viper.Viper
settings []slog.Attr settings []slog.Attr
+9 -11
View File
@@ -27,8 +27,7 @@ import (
"sneak.berlin/go/smallwebwaf/internal/remotelog" "sneak.berlin/go/smallwebwaf/internal/remotelog"
) )
// WAF is the settings of `sww waf`. A time or size limit of // WAF is the settings of `sww waf`. A time or size limit of zero is off.
// zero is off.
type WAF struct { type WAF struct {
// ListenAddr is where waf listens (SWW_WAF_LISTEN_ADDR). // ListenAddr is where waf listens (SWW_WAF_LISTEN_ADDR).
ListenAddr string ListenAddr string
@@ -77,16 +76,15 @@ type WAF struct {
BytesLimitPerHour int64 BytesLimitPerHour int64
BytesLimitPerDay int64 BytesLimitPerDay int64
BytesCounted string BytesCounted string
// LookupSource is where each client's AS number and country are // LookupSource is where each client's AS number and country are looked
// looked up (SWW_WAF_LOOKUP_SOURCE): geojs, file, or off for nowhere. // up (SWW_WAF_LOOKUP_SOURCE): geojs, file, or off for nowhere.
// LookupDBPath is the lookup database, the IPinfo Lite file looked up // LookupDBPath is the lookup database, the IPinfo Lite file looked up in
// in while LookupSource is file (SWW_WAF_LOOKUP_DB_PATH), and "" for any // while LookupSource is file (SWW_WAF_LOOKUP_DB_PATH), and "" for any
// other source. A request waits up to ASNAndCountryLookupTimeout for // other source. A request waits up to ASNAndCountryLookupTimeout for its
// its client's first answer from GeoJS while a setting needs it // client's first answer from GeoJS while a setting needs it
// (SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS), which cannot be off. // (SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS), which cannot be off.
// AddLookupHeaders is true when the app is passed the client's AS // AddLookupHeaders is true when the app is passed the client's AS number
// number and country in headers // and country in headers (SWW_WAF_ADD_LOOKUP_HEADERS).
// (SWW_WAF_ADD_LOOKUP_HEADERS).
LookupSource string LookupSource string
LookupDBPath string LookupDBPath string
ASNAndCountryLookupTimeout time.Duration ASNAndCountryLookupTimeout time.Duration
+62 -62
View File
@@ -248,8 +248,8 @@ func (e environment) viper() *viper.Viper {
return configtest.Viper(e) return configtest.Viper(e)
} }
// fromEnvironment reads the settings from env, which must be valid. // wafFromEnvironment reads waf's settings from env, which must be valid.
func fromEnvironment(t *testing.T, env environment) *config.WAF { func wafFromEnvironment(t *testing.T, env environment) *config.WAF {
t.Helper() t.Helper()
cfg, err := config.WAFFromEnvironment(env.viper()) cfg, err := config.WAFFromEnvironment(env.viper())
@@ -263,7 +263,7 @@ func fromEnvironment(t *testing.T, env environment) *config.WAF {
func TestDefaults(t *testing.T) { func TestDefaults(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{}) cfg := wafFromEnvironment(t, environment{})
wantSettings(t, cfg, config.WAF{ wantSettings(t, cfg, config.WAF{
ListenAddr: ":8080", ListenAddr: ":8080",
@@ -334,7 +334,7 @@ func TestDefaults(t *testing.T) {
func TestNoSingleRequestBreaksAByteLimitAtTheDefaults(t *testing.T) { func TestNoSingleRequestBreaksAByteLimitAtTheDefaults(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{}) cfg := wafFromEnvironment(t, environment{})
rproxy, err := rproxyConfig(map[string]string{wafBackend: backendURL}) rproxy, err := rproxyConfig(map[string]string{wafBackend: backendURL})
if err != nil { if err != nil {
@@ -359,7 +359,7 @@ func TestNoSingleRequestBreaksAByteLimitAtTheDefaults(t *testing.T) {
func TestValuesAsSet(t *testing.T) { func TestValuesAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{ cfg := wafFromEnvironment(t, environment{
listenAddr: "127.0.0.1:9000", listenAddr: "127.0.0.1:9000",
mode: "observe", mode: "observe",
allowNets: "192.0.2.7", allowNets: "192.0.2.7",
@@ -429,7 +429,7 @@ func TestIPv6GroupPrefixMaxTrackedClientsAndLogLevel(t *testing.T) {
48, 500, slog.LevelWarn, 48, 500, slog.LevelWarn,
}, },
} { } {
cfg := fromEnvironment(t, tc.env) cfg := wafFromEnvironment(t, tc.env)
if cfg.IPv6GroupPrefix != tc.prefix || if cfg.IPv6GroupPrefix != tc.prefix ||
cfg.MaxTrackedClients != tc.clients || cfg.MaxTrackedClients != tc.clients ||
cfg.LogLevel != tc.level { cfg.LogLevel != tc.level {
@@ -444,7 +444,7 @@ func TestIPv6GroupPrefixFrom32To128(t *testing.T) {
t.Parallel() t.Parallel()
for _, length := range []int{32, 128} { for _, length := range []int{32, 128} {
cfg := fromEnvironment( cfg := wafFromEnvironment(
t, t,
environment{ipv6GroupPrefix: strconv.Itoa(length)}, environment{ipv6GroupPrefix: strconv.Itoa(length)},
) )
@@ -468,7 +468,7 @@ func TestEachLogLevel(t *testing.T) {
"warn": slog.LevelWarn, "warn": slog.LevelWarn,
"error": slog.LevelError, "error": slog.LevelError,
} { } {
cfg := fromEnvironment(t, environment{logLevel: value}) cfg := wafFromEnvironment(t, environment{logLevel: value})
if cfg.LogLevel != want { if cfg.LogLevel != want {
t.Errorf( t.Errorf(
"%s=%s gave %v, want %v", "%s=%s gave %v, want %v",
@@ -484,7 +484,7 @@ func TestEachLogLevel(t *testing.T) {
func TestByteLimitSettingsAsSet(t *testing.T) { func TestByteLimitSettingsAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{ cfg := wafFromEnvironment(t, environment{
bytesLimitPerMinute: "512M", bytesLimitPerMinute: "512M",
bytesLimitPerHour: off, bytesLimitPerHour: off,
bytesLimitPerDay: "100000", bytesLimitPerDay: "100000",
@@ -500,7 +500,7 @@ func TestByteLimitSettingsAsSet(t *testing.T) {
func TestRateLimitExemptPathsAsSet(t *testing.T) { func TestRateLimitExemptPathsAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment( cfg := wafFromEnvironment(
t, t,
environment{rateLimitExemptPaths: "/assets/, /favicon.ico"}, environment{rateLimitExemptPaths: "/assets/, /favicon.ico"},
) )
@@ -542,7 +542,7 @@ func TestTrapPathsAndRefusalsLimitPerMinute(t *testing.T) {
}, },
{environment{refusalsLimitPerMinute: off}, []string{}, 0}, {environment{refusalsLimitPerMinute: off}, []string{}, 0},
} { } {
cfg := fromEnvironment(t, tc.env) cfg := wafFromEnvironment(t, tc.env)
if !slices.Equal(cfg.TrapPaths, tc.paths) || if !slices.Equal(cfg.TrapPaths, tc.paths) ||
cfg.RefusalsLimitPerMinute != tc.limit { cfg.RefusalsLimitPerMinute != tc.limit {
t.Errorf( t.Errorf(
@@ -644,7 +644,7 @@ func TestCoreRuleSetSettings(t *testing.T) {
}, },
}, },
} { } {
cfg := fromEnvironment(t, tc.env) cfg := wafFromEnvironment(t, tc.env)
got := config.WAF{ got := config.WAF{
CoreRuleSetMode: cfg.CoreRuleSetMode, CoreRuleSetMode: cfg.CoreRuleSetMode,
@@ -684,7 +684,7 @@ func TestApps(t *testing.T) {
map[string][]string{"cloud.example.com": {gitea, "wordpress"}}, map[string][]string{"cloud.example.com": {gitea, "wordpress"}},
}, },
} { } {
cfg := fromEnvironment(t, tc.env) cfg := wafFromEnvironment(t, tc.env)
if !reflect.DeepEqual(cfg.Apps, tc.want) { if !reflect.DeepEqual(cfg.Apps, tc.want) {
t.Errorf("%v gave %#v, want %#v", tc.env, cfg.Apps, tc.want) t.Errorf("%v gave %#v, want %#v", tc.env, cfg.Apps, tc.want)
} }
@@ -705,7 +705,7 @@ func TestUnsetAndEmptyAppsAreLoggedDifferently(t *testing.T) {
var out bytes.Buffer var out bytes.Buffer
slog.New(slog.NewJSONHandler(&out, nil)). slog.New(slog.NewJSONHandler(&out, nil)).
Info("starting", "settings", fromEnvironment(t, tc.env)) Info("starting", "settings", wafFromEnvironment(t, tc.env))
if !strings.Contains(out.String(), tc.want) { if !strings.Contains(out.String(), tc.want) {
t.Errorf( t.Errorf(
@@ -766,7 +766,7 @@ func TestInvalidAppsStopsTheStart(t *testing.T) {
func TestCoreRuleSetBodyInspectionSizeOf64K(t *testing.T) { func TestCoreRuleSetBodyInspectionSizeOf64K(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment( cfg := wafFromEnvironment(
t, t,
environment{coreRuleSetBodyInspectionSize: "64K"}, environment{coreRuleSetBodyInspectionSize: "64K"},
) )
@@ -845,7 +845,7 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) { func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{ cfg := wafFromEnvironment(t, environment{
instanceName: "fsn1app1/blog", instanceName: "fsn1app1/blog",
logRequestHeaders: " Accept , X-Custom", logRequestHeaders: " Accept , X-Custom",
}) })
@@ -863,7 +863,7 @@ func TestInstanceNameAndLoggedHeadersAsSet(t *testing.T) {
func TestRemoteLogSettingsDefaults(t *testing.T) { func TestRemoteLogSettingsDefaults(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{instanceName: instance}) cfg := wafFromEnvironment(t, environment{instanceName: instance})
if cfg.LogRemoteURL != nil || cfg.LogRemoteTLSCAs != nil || if cfg.LogRemoteURL != nil || cfg.LogRemoteTLSCAs != nil ||
cfg.LogRemoteBufferLines != 10000 || cfg.LogRemoteFacility != 16 || cfg.LogRemoteBufferLines != 10000 || cfg.LogRemoteFacility != 16 ||
@@ -892,7 +892,7 @@ func TestRemoteLogSettingsAsSet(t *testing.T) {
t.Fatalf("write %s: %v", caFile, err) t.Fatalf("write %s: %v", caFile, err)
} }
cfg := fromEnvironment(t, environment{ cfg := wafFromEnvironment(t, environment{
logRemoteURL: "syslog+tls://logs.example:6514", logRemoteURL: "syslog+tls://logs.example:6514",
logRemoteTLSCAFile: caFile, logRemoteTLSCAFile: caFile,
logRemoteBufferLines: "500", logRemoteBufferLines: "500",
@@ -927,13 +927,13 @@ func TestRemoteLogURLForms(t *testing.T) {
"syslog+tcp://[2001:db8::1]:514", "syslog+tcp://[2001:db8::1]:514",
"syslog+tls://logs.example:6514/", "syslog+tls://logs.example:6514/",
} { } {
cfg := fromEnvironment(t, environment{logRemoteURL: value}) cfg := wafFromEnvironment(t, environment{logRemoteURL: value})
if cfg.LogRemoteURL.String() != value { if cfg.LogRemoteURL.String() != value {
t.Errorf("%s read as %v", value, cfg.LogRemoteURL) t.Errorf("%s read as %v", value, cfg.LogRemoteURL)
} }
} }
cfg := fromEnvironment(t, environment{logRemoteURL: ""}) cfg := wafFromEnvironment(t, environment{logRemoteURL: ""})
if cfg.LogRemoteURL != nil { if cfg.LogRemoteURL != nil {
t.Errorf( t.Errorf(
"set but empty, %s read as %v", "set but empty, %s read as %v",
@@ -950,7 +950,7 @@ func TestRemoteLogFacilitiesByNumber(t *testing.T) {
"kern": 0, "user": 1, "auth": 4, "authpriv": 10, "ftp": 11, "kern": 0, "user": 1, "auth": 4, "authpriv": 10, "ftp": 11,
"local0": 16, "local5": 21, "local7": 23, "local0": 16, "local5": 21, "local7": 23,
} { } {
cfg := fromEnvironment(t, environment{logRemoteFacility: name}) cfg := wafFromEnvironment(t, environment{logRemoteFacility: name})
if cfg.LogRemoteFacility != number { if cfg.LogRemoteFacility != number {
t.Errorf( t.Errorf(
"%s read as %d, want %d", "%s read as %d, want %d",
@@ -1036,7 +1036,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
cfg := fromEnvironment(t, environment{instanceName: spaced}) cfg := wafFromEnvironment(t, environment{instanceName: spaced})
if cfg.LogRemoteAppName != spaced { if cfg.LogRemoteAppName != spaced {
t.Errorf( t.Errorf(
"not sending, %s is %q", "not sending, %s is %q",
@@ -1047,7 +1047,7 @@ func TestInstanceNameNotAnAppNameStopsTheStartOnlyWhileSending(
sending[logRemoteAppName] = instance sending[logRemoteAppName] = instance
cfg = fromEnvironment(t, sending) cfg = wafFromEnvironment(t, sending)
if cfg.LogRemoteAppName != instance { if cfg.LogRemoteAppName != instance {
t.Errorf("set to %s, %s is %q", instance, logRemoteAppName, t.Errorf("set to %s, %s is %q", instance, logRemoteAppName,
cfg.LogRemoteAppName) cfg.LogRemoteAppName)
@@ -1073,7 +1073,7 @@ func TestAppNameSetStopsTheStartWhileSending(t *testing.T) {
func TestAlertSettingsDefaults(t *testing.T) { func TestAlertSettingsDefaults(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{}) cfg := wafFromEnvironment(t, environment{})
if cfg.AlertWebhookURL != nil || len(cfg.AlertWebhookHeaders) != 0 || if cfg.AlertWebhookURL != nil || len(cfg.AlertWebhookHeaders) != 0 ||
cfg.AlertSlackWebhookURL != nil || cfg.AlertNtfyURL != nil || cfg.AlertSlackWebhookURL != nil || cfg.AlertNtfyURL != nil ||
@@ -1106,7 +1106,7 @@ func TestAlertSettingsAsSet(t *testing.T) {
token = "tk_0123456789abcdefghijklmnopq" token = "tk_0123456789abcdefghijklmnopq"
) )
cfg := fromEnvironment(t, environment{ cfg := wafFromEnvironment(t, environment{
alertWebhookURL: webhook, alertWebhookURL: webhook,
alertWebhookHeaders: "Authorization: Bearer abc:def , x-team:ops", alertWebhookHeaders: "Authorization: Bearer abc:def , x-team:ops",
alertSlackWebhookURL: slack, alertSlackWebhookURL: slack,
@@ -1144,7 +1144,7 @@ func TestAlertSettingsAsSet(t *testing.T) {
func TestAlertSettingsSetEmptyOrOff(t *testing.T) { func TestAlertSettingsSetEmptyOrOff(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{ cfg := wafFromEnvironment(t, environment{
alertWebhookURL: "", alertSlackWebhookURL: "", alertNtfyURL: "", alertWebhookURL: "", alertSlackWebhookURL: "", alertNtfyURL: "",
alertNtfyToken: "", alertEvents: "", alertCooldown: off, alertMaxPerHour: off, alertNtfyToken: "", alertEvents: "", alertCooldown: off, alertMaxPerHour: off,
}) })
@@ -1202,7 +1202,7 @@ func TestWebhookHeadersAreLoggedMaskedAndNeverShown(t *testing.T) {
const secret = "Bearer 0123456789abcdef" const secret = "Bearer 0123456789abcdef"
cfg := fromEnvironment(t, environment{ cfg := wafFromEnvironment(t, environment{
alertWebhookHeaders: "Authorization:" + secret + ",X-Team:ops", alertWebhookHeaders: "Authorization:" + secret + ",X-Team:ops",
}) })
@@ -1242,7 +1242,7 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) {
"https://hooks.example:8443?token=" + secret: "https://hooks.example:8443/********", "https://hooks.example:8443?token=" + secret: "https://hooks.example:8443/********",
"http://[2001:db8::1]:8080": "http://[2001:db8::1]:8080", "http://[2001:db8::1]:8080": "http://[2001:db8::1]:8080",
} { } {
cfg := fromEnvironment(t, environment{alertWebhookURL: value}) cfg := wafFromEnvironment(t, environment{alertWebhookURL: value})
var out bytes.Buffer var out bytes.Buffer
@@ -1278,7 +1278,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
const token = "tk_0123456789abcdefghijklmnopq" const token = "tk_0123456789abcdefghijklmnopq"
cfg := fromEnvironment(t, environment{ cfg := wafFromEnvironment(t, environment{
alertSlackWebhookURL: "https://hooks.slack.example/services/T0123/B4567/abcdef", alertSlackWebhookURL: "https://hooks.slack.example/services/T0123/B4567/abcdef",
alertNtfyURL: "https://ntfy.example/sww-alerts", alertNtfyURL: "https://ntfy.example/sww-alerts",
alertNtfyToken: token, alertNtfyToken: token,
@@ -1345,7 +1345,7 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(
t.Errorf("error %v, want %s", err, want) t.Errorf("error %v, want %s", err, want)
} }
cfg := fromEnvironment(t, environment{instanceName: name}) cfg := wafFromEnvironment(t, environment{instanceName: name})
if cfg.InstanceName != name { if cfg.InstanceName != name {
t.Errorf( t.Errorf(
"not sending to ntfy, %s is %q", "not sending to ntfy, %s is %q",
@@ -1396,7 +1396,7 @@ func TestCodeInAllowedCountriesAndTagDenyStopsTheStart(t *testing.T) {
func TestLookupSettingsAsSet(t *testing.T) { func TestLookupSettingsAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{ cfg := wafFromEnvironment(t, environment{
asnAndCountryLookupTimeout: "500", addLookupHeaders: enabled, asnAndCountryLookupTimeout: "500", addLookupHeaders: enabled,
}) })
wantLookupSettings(t, cfg, config.WAF{ wantLookupSettings(t, cfg, config.WAF{
@@ -1404,7 +1404,7 @@ func TestLookupSettingsAsSet(t *testing.T) {
AddLookupHeaders: true, AddLookupHeaders: true,
}) })
cfg = fromEnvironment(t, environment{lookupSource: off}) cfg = wafFromEnvironment(t, environment{lookupSource: off})
wantLookupSettings(t, cfg, config.WAF{ wantLookupSettings(t, cfg, config.WAF{
LookupSource: off, ASNAndCountryLookupTimeout: 800 * time.Millisecond, LookupSource: off, ASNAndCountryLookupTimeout: 800 * time.Millisecond,
}) })
@@ -1415,7 +1415,7 @@ func TestLookupDBPathGoesWithTheFileSourceAlone(t *testing.T) {
const path = "/var/lib/ipinfo/ipinfo_lite.mmdb" const path = "/var/lib/ipinfo/ipinfo_lite.mmdb"
cfg := fromEnvironment( cfg := wafFromEnvironment(
t, t,
environment{lookupSource: fileSource, lookupDBPath: path}, environment{lookupSource: fileSource, lookupDBPath: path},
) )
@@ -1508,13 +1508,13 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
} }
} }
fromEnvironment(t, env) wafFromEnvironment(t, env)
} }
func TestAnomalySettingsDefaults(t *testing.T) { func TestAnomalySettingsDefaults(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{}) cfg := wafFromEnvironment(t, environment{})
wantAllOff(t, cfg) wantAllOff(t, cfg)
if cfg.AnomalyNetV4Prefix != 24 || cfg.AnomalyNetV6Prefix != 48 || if cfg.AnomalyNetV4Prefix != 24 || cfg.AnomalyNetV6Prefix != 48 ||
@@ -1556,7 +1556,7 @@ func TestAnomalySettingsAsSet(t *testing.T) {
} }
} }
cfg := fromEnvironment(t, env) cfg := wafFromEnvironment(t, env)
if got := thresholdsByScope(cfg); !maps.Equal(got, want) { if got := thresholdsByScope(cfg); !maps.Equal(got, want) {
t.Errorf("thresholds by scope\n%+v\nwant\n%+v", got, want) t.Errorf("thresholds by scope\n%+v\nwant\n%+v", got, want)
@@ -1589,7 +1589,7 @@ func TestAnomalySettingsAsSet(t *testing.T) {
env[name] = off env[name] = off
} }
wantAllOff(t, fromEnvironment(t, env)) wantAllOff(t, wafFromEnvironment(t, env))
} }
// thresholdsByScope returns cfg's anomaly thresholds, each by the prefix // thresholdsByScope returns cfg's anomaly thresholds, each by the prefix
@@ -1678,7 +1678,7 @@ func TestTagSettingsAsSet(t *testing.T) {
// Names and tags in either case, an AS number with leading zeros, and a // Names and tags in either case, an AS number with leading zeros, and a
// name holding a =. // name holding a =.
cfg := fromEnvironment(t, environment{ cfg := wafFromEnvironment(t, environment{
tagLists: "TorExit=" + torURL + tagLists: "TorExit=" + torURL +
", vpn=mullvad=https://example.com/mullvad.txt", ", vpn=mullvad=https://example.com/mullvad.txt",
dnsblZones: "Zen.example", dnsblZones: "Zen.example",
@@ -1717,7 +1717,7 @@ func TestRouteWatchURLAndPrefixTags(t *testing.T) {
const rw = "https://routewatch.example" const rw = "https://routewatch.example"
if cfg := fromEnvironment(t, environment{}); cfg.RouteWatchURL != "" { if cfg := wafFromEnvironment(t, environment{}); cfg.RouteWatchURL != "" {
t.Errorf( t.Errorf(
"default %s is %q, want unset", "default %s is %q, want unset",
routeWatchURL, routeWatchURL,
@@ -1725,7 +1725,7 @@ func TestRouteWatchURLAndPrefixTags(t *testing.T) {
) )
} }
cfg := fromEnvironment(t, environment{ cfg := wafFromEnvironment(t, environment{
routeWatchURL: rw, tagDeny: "prefix=2001:DB8::/32", routeWatchURL: rw, tagDeny: "prefix=2001:DB8::/32",
tagLimitPercent: "prefix=192.0.2.0/24:50", tagLimitPercent: "prefix=192.0.2.0/24:50",
}) })
@@ -1742,7 +1742,7 @@ func TestRouteWatchURLAndPrefixTags(t *testing.T) {
} }
// routewatch gives asn= tags too, so an asn= tag needs no lookups while it is set. // routewatch gives asn= tags too, so an asn= tag needs no lookups while it is set.
fromEnvironment(t, environment{ wafFromEnvironment(t, environment{
lookupSource: off, routeWatchURL: rw, tagDeny: "asn=64496", lookupSource: off, routeWatchURL: rw, tagDeny: "asn=64496",
}) })
@@ -1919,7 +1919,7 @@ func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
func TestBiasedThresholdsAsSet(t *testing.T) { func TestBiasedThresholdsAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{}) cfg := wafFromEnvironment(t, environment{})
if len(cfg.ASNBytesLimitPercent) != 0 || if len(cfg.ASNBytesLimitPercent) != 0 ||
len(cfg.CountryBytesLimitPercent) != 0 || len(cfg.CountryBytesLimitPercent) != 0 ||
cfg.UnknownCountryLimitPercent != 100 { cfg.UnknownCountryLimitPercent != 100 {
@@ -1934,7 +1934,7 @@ func TestBiasedThresholdsAsSet(t *testing.T) {
// AS numbers and countries in either case, an AS number with leading // AS numbers and countries in either case, an AS number with leading
// zeros, 0 and 100. // zeros, 0 and 100.
cfg = fromEnvironment(t, environment{ cfg = wafFromEnvironment(t, environment{
asnBytesLimitPercent: "as16276:75", asnBytesLimitPercent: "as16276:75",
countryBytesLimitPercent: "ru:10", countryBytesLimitPercent: "ru:10",
unknownCountryLimitPercent: "0", unknownCountryLimitPercent: "0",
@@ -2016,7 +2016,7 @@ const (
func TestReputationSettingsAsSet(t *testing.T) { func TestReputationSettingsAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{}) cfg := wafFromEnvironment(t, environment{})
if len(cfg.BlocklistURLs) != 0 || cfg.BlocklistRefresh != 24*time.Hour || if len(cfg.BlocklistURLs) != 0 || cfg.BlocklistRefresh != 24*time.Hour ||
cfg.BlocklistAction != actionDeny || cfg.ASNLimitPercentURL != "" { cfg.BlocklistAction != actionDeny || cfg.ASNLimitPercentURL != "" {
t.Errorf( t.Errorf(
@@ -2043,7 +2043,7 @@ func TestReputationSettingsAsSet(t *testing.T) {
{"limit:0", actionLimit, 0}, {"limit:0", actionLimit, 0},
} { } {
// An hour, the shortest refresh allowed. // An hour, the shortest refresh allowed.
cfg := fromEnvironment(t, environment{ cfg := wafFromEnvironment(t, environment{
blocklistURLs: dropURL + ", " + torURL, blocklistRefresh: "1h", blocklistURLs: dropURL + ", " + torURL, blocklistRefresh: "1h",
blocklistAction: tc.value, asnLimitPercentURL: asnURL, blocklistAction: tc.value, asnLimitPercentURL: asnURL,
}) })
@@ -2140,7 +2140,7 @@ const (
func TestDNSBLSettingsAsSet(t *testing.T) { func TestDNSBLSettingsAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{}) cfg := wafFromEnvironment(t, environment{})
if len(cfg.DNSBLZones) != 0 || cfg.DNSBLResolver.IsValid() || if len(cfg.DNSBLZones) != 0 || cfg.DNSBLResolver.IsValid() ||
cfg.DNSBLAbuseIPDBAction != actionLimit || cfg.DNSBLAbuseIPDBAction != actionLimit ||
cfg.DNSBLAbuseIPDBLimitPercent != 25 || cfg.DNSBLAbuseIPDBLimitPercent != 25 ||
@@ -2188,7 +2188,7 @@ func TestDNSBLSettingsAsSet(t *testing.T) {
[]string{dronebl}, "[2001:db8::53]:5353", actionLimit, 0, []string{dronebl}, "[2001:db8::53]:5353", actionLimit, 0,
}, },
} { } {
cfg := fromEnvironment(t, environment{ cfg := wafFromEnvironment(t, environment{
dnsblZones: tc.zones, dnsblResolver: tc.resolver, dnsblAbuseIPDBAction: tc.action, dnsblZones: tc.zones, dnsblResolver: tc.resolver, dnsblAbuseIPDBAction: tc.action,
ipReputationCacheTTL: "12h", dnsblAbuseIPDBTimeout: "3000", ipReputationCacheTTL: "12h", dnsblAbuseIPDBTimeout: "3000",
}) })
@@ -2310,7 +2310,7 @@ func TestMaskZoneKeyMasksTheFirstLabelOfAZoneUnderDqSpamhausNet(
func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) { func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment( cfg := wafFromEnvironment(
t, t,
environment{dnsblZones: dronebl + ", " + spamhaus}, environment{dnsblZones: dronebl + ", " + spamhaus},
) )
@@ -2375,7 +2375,7 @@ func TestDNSBLZoneKeyIsLoggedMaskedAndNeverShown(t *testing.T) {
func TestAbuseIPDBSettingsAsSet(t *testing.T) { func TestAbuseIPDBSettingsAsSet(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{}) cfg := wafFromEnvironment(t, environment{})
if cfg.AbuseIPDBKey != "" || cfg.AbuseIPDBMinScore != 75 || if cfg.AbuseIPDBKey != "" || cfg.AbuseIPDBMinScore != 75 ||
cfg.AbuseIPDBDailyBudget != 900 { cfg.AbuseIPDBDailyBudget != 900 {
t.Errorf( t.Errorf(
@@ -2387,7 +2387,7 @@ func TestAbuseIPDBSettingsAsSet(t *testing.T) {
) )
} }
cfg = fromEnvironment(t, environment{ cfg = wafFromEnvironment(t, environment{
abuseIPDBKey: token, abuseIPDBMinScore: "0", abuseIPDBDailyBudget: "1", abuseIPDBKey: token, abuseIPDBMinScore: "0", abuseIPDBDailyBudget: "1",
}) })
if cfg.AbuseIPDBKey != token || cfg.AbuseIPDBMinScore != 0 || if cfg.AbuseIPDBKey != token || cfg.AbuseIPDBMinScore != 0 ||
@@ -2442,7 +2442,7 @@ func TestInvalidAbuseIPDBSettingStopsTheStartSayingWhatIsWrong(
func TestAbuseIPDBKeyIsLoggedMasked(t *testing.T) { func TestAbuseIPDBKeyIsLoggedMasked(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{abuseIPDBKey: token}) cfg := wafFromEnvironment(t, environment{abuseIPDBKey: token})
var out bytes.Buffer var out bytes.Buffer
@@ -2459,7 +2459,7 @@ func TestAbuseIPDBKeyIsLoggedMasked(t *testing.T) {
func TestCrowdSecSettingsGiveTheDecisionListAndTheKey(t *testing.T) { func TestCrowdSecSettingsGiveTheDecisionListAndTheKey(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{}) cfg := wafFromEnvironment(t, environment{})
if cfg.CrowdSecDecisionsURL != "" || cfg.CrowdSecKey != "" { if cfg.CrowdSecDecisionsURL != "" || cfg.CrowdSecKey != "" {
t.Errorf( t.Errorf(
"by default, the decision list %q and the key %q, want neither", "by default, the decision list %q and the key %q, want neither",
@@ -2474,7 +2474,7 @@ func TestCrowdSecSettingsGiveTheDecisionListAndTheKey(t *testing.T) {
"https://crowdsec.example/lapi/": "https://crowdsec.example/lapi/v1/decisions", "https://crowdsec.example/lapi/": "https://crowdsec.example/lapi/v1/decisions",
"https://crowdsec.example:8443/x": "https://crowdsec.example:8443/x/v1/decisions", "https://crowdsec.example:8443/x": "https://crowdsec.example:8443/x/v1/decisions",
} { } {
cfg := fromEnvironment( cfg := wafFromEnvironment(
t, t,
environment{crowdSecURL: lapi, crowdSecKey: token}, environment{crowdSecURL: lapi, crowdSecKey: token},
) )
@@ -2568,7 +2568,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
func TestCrowdSecKeyIsLoggedMasked(t *testing.T) { func TestCrowdSecKeyIsLoggedMasked(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{ cfg := wafFromEnvironment(t, environment{
crowdSecURL: "http://172.17.0.1:8080", crowdSecKey: token, crowdSecURL: "http://172.17.0.1:8080", crowdSecKey: token,
}) })
@@ -2591,7 +2591,7 @@ func TestCrowdSecKeyIsLoggedMasked(t *testing.T) {
func TestSizesAndOff(t *testing.T) { func TestSizesAndOff(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{ cfg := wafFromEnvironment(t, environment{
bytesLimitPerMinute: "3G", bytesLimitPerMinute: "3G",
bytesLimitPerHour: off, bytesLimitPerHour: off,
alertCooldown: off, alertCooldown: off,
@@ -2611,7 +2611,7 @@ func TestSizesAndOff(t *testing.T) {
func TestRateLimitsOff(t *testing.T) { func TestRateLimitsOff(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{ cfg := wafFromEnvironment(t, environment{
rateLimitPerMinute: off, rateLimitPerMinute: off,
rateLimitPerHour: off, rateLimitPerHour: off,
rateLimitPerDay: off, rateLimitPerDay: off,
@@ -2631,7 +2631,7 @@ func TestRateLimitsOff(t *testing.T) {
func TestBanResponseCloseIsZero(t *testing.T) { func TestBanResponseCloseIsZero(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{banResponse: "close"}) cfg := wafFromEnvironment(t, environment{banResponse: "close"})
if cfg.BanResponse != 0 { if cfg.BanResponse != 0 {
t.Errorf("close read as %d, want 0", cfg.BanResponse) t.Errorf("close read as %d, want 0", cfg.BanResponse)
} }
@@ -2883,7 +2883,7 @@ func TestShortTokenStopsTheStartWithoutShowingIt(t *testing.T) {
func TestTokensAreReadAndLoggedMasked(t *testing.T) { func TestTokensAreReadAndLoggedMasked(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment( cfg := wafFromEnvironment(
t, t,
environment{adminToken: otherToken, metricsToken: token}, environment{adminToken: otherToken, metricsToken: token},
) )
@@ -2915,7 +2915,7 @@ func TestSettingFromFileLosesOneNewlineAndNoMore(t *testing.T) {
token + "\n\n": token + "\n", token + "\n\n": token + "\n",
token + " \n": token + " ", token + " \n": token + " ",
} { } {
cfg := fromEnvironment(t, environment{ cfg := wafFromEnvironment(t, environment{
metricsToken + "_FILE": writeFile(t, contents), metricsToken + "_FILE": writeFile(t, contents),
}) })
if cfg.MetricsToken != want { if cfg.MetricsToken != want {
@@ -2991,7 +2991,7 @@ func TestTokenFromFileIsLoggedMaskedWithTheFile(t *testing.T) {
t.Parallel() t.Parallel()
path := writeFile(t, token+"\n") path := writeFile(t, token+"\n")
cfg := fromEnvironment(t, environment{metricsToken + "_FILE": path}) cfg := wafFromEnvironment(t, environment{metricsToken + "_FILE": path})
var out bytes.Buffer var out bytes.Buffer
@@ -3021,7 +3021,7 @@ func TestTokenFromFileIsLoggedMaskedWithTheFile(t *testing.T) {
func TestRemoteLogCAFileIsNotReadAsAFileInItsTurn(t *testing.T) { func TestRemoteLogCAFileIsNotReadAsAFileInItsTurn(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{ cfg := wafFromEnvironment(t, environment{
logRemoteTLSCAFile + "_FILE": writeFile(t, "/nonexistent/ca.pem\n"), logRemoteTLSCAFile + "_FILE": writeFile(t, "/nonexistent/ca.pem\n"),
}) })
if cfg.LogRemoteTLSCAs != nil { if cfg.LogRemoteTLSCAs != nil {
@@ -3048,7 +3048,7 @@ func writeFile(t *testing.T, contents string) string {
func TestLogsEachSettingWithItsValue(t *testing.T) { func TestLogsEachSettingWithItsValue(t *testing.T) {
t.Parallel() t.Parallel()
cfg := fromEnvironment(t, environment{rateLimitPerMinute: "45"}) cfg := wafFromEnvironment(t, environment{rateLimitPerMinute: "45"})
hostname, _ := os.Hostname() hostname, _ := os.Hostname()
want := map[string]string{ want := map[string]string{
+3 -3
View File
@@ -42,9 +42,9 @@ const (
otherZone = "other.example" otherZone = "other.example"
// cacheTTL is the tests' SWW_WAF_IP_REPUTATION_CACHE_TTL, and timeout // cacheTTL is the tests' SWW_WAF_IP_REPUTATION_CACHE_TTL, and timeout
// their SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS: a second, the least time // their SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS: a second, the least time
// /etc/resolv.conf // /etc/resolv.conf can have Go's resolver wait for one server, so that
// can have Go's resolver wait for one server, so that it is the // it is the DNSBL's own timeout that ends a query, whatever that file
// DNSBL's own timeout that ends a query, whatever that file says. // says.
cacheTTL = 24 * time.Hour cacheTTL = 24 * time.Hour
timeout = time.Second timeout = time.Second
// listed and unlisted are clients zone is asked about by the names // listed and unlisted are clients zone is asked about by the names
+137 -2
View File
@@ -26,6 +26,10 @@ import (
// post is the start of a POST whose headers end with what follows it. // post is the start of a POST whose headers end with what follows it.
const post = "POST / HTTP/1.1\r\nHost: site.example\r\n" const post = "POST / HTTP/1.1\r\nHost: site.example\r\n"
// limitPastFirstPart is a SWW_RPROXY_MAX_REQUEST_SIZE past the first part of a
// body, the 64 KiB rproxy reads before it asks waf.
const limitPastFirstPart = "128K"
// announcedLength is the header that announces a body of size bytes, and the // announcedLength is the header that announces a body of size bytes, and the
// end of the headers. // end of the headers.
func announcedLength(size int) string { func announcedLength(size int) string {
@@ -75,7 +79,10 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) {
size, asked int size, asked int
}{ }{
{"with the limit within the first part", "1K", 1025, 1024}, {"with the limit within the first part", "1K", 1025, 1024},
{"with the limit past the first part", "128K", 129 << 10, questionBodyMaxBytes}, {
"with the limit past the first part", limitPastFirstPart, 129 << 10,
questionBodyMaxBytes,
},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -136,6 +143,134 @@ func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
} }
} }
func TestAVisitorSendingConnectionCloseReadsAnAnswerBeforeTheRestOfItsBody(
t *testing.T,
) {
t.Parallel()
for _, tc := range []struct {
name string
waf http.HandlerFunc
rest int
status int
}{
{"over the size limit", allow, 65 << 10, http.StatusRequestEntityTooLarge},
{"denied", deny, 65 << 10, http.StatusForbidden},
// rproxy reads 256 KiB of this rest. What it leaves unread must fit in the
// connection's buffers. Otherwise the visitor is still sending when the
// connection closes, and gets a reset.
{
"with more than 256 KiB left", allow, 272 << 10,
http.StatusRequestEntityTooLarge,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
standIn := startWAF(t, tc.waf)
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
addr, _ := startRProxy(t, standIn.URL, app.URL,
map[string]string{maxRequestSize: limitPastFirstPart})
conn := dial(t, addr)
send(t, conn, post+"Connection: close\r\n"+
announcedLength(questionBodyMaxBytes+tc.rest)+
strings.Repeat("x", questionBodyMaxBytes))
reader := bufio.NewReader(conn)
res, err := http.ReadResponse(reader, nil)
if err != nil {
t.Fatalf("read the answer: %v", err)
}
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
if res.StatusCode != tc.status {
t.Errorf("status %d, want %d", res.StatusCode, tc.status)
}
// The rest goes in small parts, so that a reset fails a later one.
for sent := 0; sent < tc.rest; sent += 1 << 10 {
send(t, conn, strings.Repeat("x", 1<<10))
}
got, err := io.ReadAll(reader)
if err != nil || len(got) != 0 {
t.Errorf(
"after the answer, read %q and %v, want the end",
got,
err,
)
}
})
}
}
func TestCloseDoesNotWaitForTheRestOfTheBodyOfAVisitorSendingConnectionClose(
t *testing.T,
) {
t.Parallel()
standIn := startWAF(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(dispositionHeader, dispositionClose)
w.WriteHeader(http.StatusOK)
})
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
// The request time limit is far longer than dial's deadline.
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "120000",
})
conn := dial(t, addr)
send(t, conn, post+"Connection: close\r\n"+
announcedLength(2*questionBodyMaxBytes)+
strings.Repeat("x", questionBodyMaxBytes))
// If the connection stays open, dial's deadline ends this read with a timeout.
text, err := io.ReadAll(conn)
if err != nil || len(text) != 0 {
t.Errorf(
"read %q (%v), want the connection closed without an answer",
text,
err,
)
}
}
func TestAVisitorSendingConnectionCloseGetsTheEndOfAnAnswerWithoutContentLengthAtOnce(
t *testing.T,
) {
t.Parallel()
standIn := startWAF(t, allow)
// The flush has the app's server send greeting in chunks instead of adding a
// Content-Length.
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
rc := http.NewResponseController(w)
_ = rc.EnableFullDuplex()
_, _ = io.WriteString(w, greeting)
_ = rc.Flush()
})
// The request time limit is far longer than dial's deadline.
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
maxRequestTime: "120000",
})
conn := dial(t, addr)
send(t, conn, post+"Connection: close\r\n"+
announcedLength(2*questionBodyMaxBytes)+
strings.Repeat("x", questionBodyMaxBytes+1))
wantGreeting(t, readAnswer(t, conn))
}
func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) { func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
t.Parallel() t.Parallel()
@@ -148,7 +283,7 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
mayReachApp bool mayReachApp bool
}{ }{
{"within the first part", "1K", 2 << 10, false}, {"within the first part", "1K", 2 << 10, false},
{"past the first part", "128K", 129 << 10, true}, {"past the first part", limitPastFirstPart, 129 << 10, true},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
+34 -5
View File
@@ -38,6 +38,10 @@ const plainText = "text/plain; charset=utf-8"
// and the visitor then gets a closed connection and no answer. // and the visitor then gets a closed connection and no answer.
const minSendTime = 10 * time.Second const minSendTime = 10 * time.Second
// maxBodyReadAfterAnswer is the most of a body left unread that Go's server
// reads once the handler returns, for a visitor without Connection: close.
const maxBodyReadAfterAnswer = 256 << 10
// errResponseTooLate refuses an app's answer that arrives once // errResponseTooLate refuses an app's answer that arrives once
// SWW_RPROXY_MAX_RESPONSE_TIME_MS has run out. // SWW_RPROXY_MAX_RESPONSE_TIME_MS has run out.
var errResponseTooLate = errors.New( var errResponseTooLate = errors.New(
@@ -339,8 +343,8 @@ func (rq *request) answer(status int) {
} }
// A visitor found too slow is read no more; any other may go on sending until // A visitor found too slow is read no more; any other may go on sending until
// its time is up, so that Go's server can read the rest of the body and end // its time is up, so that the rest of the body is read and the request ends
// the request cleanly. // cleanly: by Go's server, or by readRestOfBody.
deadline := rq.requestDeadline() deadline := rq.requestDeadline()
if status == http.StatusRequestTimeout { if status == http.StatusRequestTimeout {
deadline = time.Now() deadline = time.Now()
@@ -524,10 +528,35 @@ func (rq *request) stopReadingBody(deadline time.Time) {
_ = rq.rc.SetReadDeadline(deadline) _ = rq.rc.SetReadDeadline(deadline)
} }
// readRestOfBody reads up to 256 KiB of the rest of the body once the visitor
// has been answered with a Content-Length, until the read deadline already set on
// the connection. It does this only for a body with a declared length from a
// visitor that sent Connection: close, because Go's server reads none of that
// rest. Without the read, the visitor, still sending, gets a reset and may never
// read the answer. Go's server ends an answer without a Content-Length only once
// the handler returns, so reading first would hold back the end of that answer.
// Past 256 KiB, the MaxBytesReader made with Go's own ResponseWriter has Go's
// server tell the visitor it sends no more, then wait 500 ms before it closes
// the connection.
func (rq *request) readRestOfBody() {
if !rq.in.Close || rq.in.ContentLength <= 0 || rq.body.received.Load() ||
rq.out.Header().Get("Content-Length") == "" ||
rq.out.status == http.StatusSwitchingProtocols {
return
}
rest := http.MaxBytesReader(
rq.out.ResponseWriter,
rq.in.Body,
maxBodyReadAfterAnswer,
)
_, _ = io.Copy(io.Discard, rest)
}
// stopPassingOnBody ends the transport's reading of a visitor's body that has // stopPassingOnBody ends the transport's reading of a visitor's body that has
// not arrived whole, and lets Go's server read the rest until deadline. Go's // not arrived whole, and lets Go's server, or readRestOfBody, read the rest until
// server reads it once the handler returns, but with no deadline at all if // deadline. Go's server reads it once the handler returns, but with no deadline
// another read of it is still waiting. // at all if another read of it is still waiting.
func (rq *request) stopPassingOnBody(deadline time.Time) { func (rq *request) stopPassingOnBody(deadline time.Time) {
if rq.body == nil || rq.body.received.Load() { if rq.body == nil || rq.body.received.Load() {
return return
+2 -2
View File
@@ -110,8 +110,7 @@ func New(params Params) *Server {
// WaitForReports returns once the reports being sent have gone or failed, // WaitForReports returns once the reports being sent have gone or failed,
// which SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS bounds. Any report after this call // which SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS bounds. Any report after this call
// is dropped: that of // is dropped: that of a WebSocket that closes while rproxy stops, for one.
// a WebSocket that closes while rproxy stops, for one.
func (s *Server) WaitForReports() { func (s *Server) WaitForReports() {
s.h.reportsMu.Lock() s.h.reportsMu.Lock()
s.h.reportsStopped = true s.h.reportsStopped = true
@@ -171,6 +170,7 @@ func newWAFTransport() *http.Transport {
// it writes the request's log line and sends its report. // it writes the request's log line and sends its report.
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rq := h.newRequest(w, r) rq := h.newRequest(w, r)
defer rq.readRestOfBody()
defer rq.finish(r.Context()) defer rq.finish(r.Context())
if r.Method == http.MethodGet && r.URL.Path == waf.HealthPath { if r.Method == http.MethodGet && r.URL.Path == waf.HealthPath {
+4 -4
View File
@@ -16,10 +16,10 @@ import (
// RunRProxy is `sww rproxy`: it reads rproxy's settings, then serves // RunRProxy is `sww rproxy`: it reads rproxy's settings, then serves
// requests until ctx is done. Then it gives the requests in progress // requests until ctx is done. Then it gives the requests in progress
// shutdownTimeout to finish, and the reports being sent // shutdownTimeout to finish, and the reports being sent
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. It // SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. It returns the process's exit status, 1
// returns the process's exit status, 1 when rproxy cannot start. Its process // when rproxy cannot start. Its process messages name the host's name as
// messages name the host's name as instance, and it writes those at info or // instance, and it writes those at info or more severe: rproxy has no
// more severe: rproxy has no setting for either. // setting for either.
func RunRProxy(ctx context.Context, params Params) int { func RunRProxy(ctx context.Context, params Params) int {
hostname, _ := os.Hostname() // "" when the host has no name to give hostname, _ := os.Hostname() // "" when the host has no name to give
processLog := requestlog.NewProcessLogger( processLog := requestlog.NewProcessLogger(
+3 -3
View File
@@ -34,8 +34,8 @@ const (
answerTimeLimit = 2 * time.Second answerTimeLimit = 2 * time.Second
// defaultLookupWait is the default of SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS. // defaultLookupWait is the default of SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS.
defaultLookupWait = 800 * time.Millisecond defaultLookupWait = 800 * time.Millisecond
// defaultReputationWait is the default of SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS. // defaultDNSBLAbuseIPDBWait is the default of SWW_WAF_DNSBL_ABUSEIPDB_TIMEOUT_MS.
defaultReputationWait = 2 * time.Second defaultDNSBLAbuseIPDBWait = 2 * time.Second
// The header limits SPEC.md gives. // The header limits SPEC.md gives.
cookieLimit = 50 cookieLimit = 50
headerFieldLimit = 100 headerFieldLimit = 100
@@ -844,7 +844,7 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
// The query is answered, and the check with AbuseIPDB abandoned, // The query is answered, and the check with AbuseIPDB abandoned,
// before the bubble ends. // before the bubble ends.
releaseResolver() releaseResolver()
time.Sleep(defaultReputationWait) time.Sleep(defaultDNSBLAbuseIPDBWait)
}) })
} }
+5 -5
View File
@@ -20,8 +20,8 @@ type percentage struct {
// client's limits by its lookup: SWW_WAF_ASN_BYTES_LIMIT_PERCENT or // client's limits by its lookup: SWW_WAF_ASN_BYTES_LIMIT_PERCENT or
// SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT is not empty, // SWW_WAF_COUNTRY_BYTES_LIMIT_PERCENT is not empty,
// SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT is below 100, or // SWW_WAF_UNKNOWN_COUNTRY_LIMIT_PERCENT is below 100, or
// SWW_WAF_ASN_LIMIT_PERCENT_URL is set. The client's lookup is // SWW_WAF_ASN_LIMIT_PERCENT_URL is set. The client's lookup is then needed
// then needed before its request goes on. // before its request goes on.
func biasedThresholdsSet(cfg *config.WAF) bool { func biasedThresholdsSet(cfg *config.WAF) bool {
return len(cfg.ASNBytesLimitPercent) > 0 || return len(cfg.ASNBytesLimitPercent) > 0 ||
len(cfg.CountryBytesLimitPercent) > 0 || len(cfg.CountryBytesLimitPercent) > 0 ||
@@ -70,10 +70,10 @@ func (rq *request) limitPercentages() (percentage, percentage) {
} }
} }
reputationListed := percentage{percent: whole} dnsblAbuseIPDBListed := percentage{percent: whole}
if (rq.dnsblListed || rq.abuseIPDBHit) && if (rq.dnsblListed || rq.abuseIPDBHit) &&
cfg.DNSBLAbuseIPDBAction == "limit" { cfg.DNSBLAbuseIPDBAction == "limit" {
reputationListed = percentage{ dnsblAbuseIPDBListed = percentage{
cfg.DNSBLAbuseIPDBLimitPercent, cfg.DNSBLAbuseIPDBLimitPercent,
"SWW_WAF_DNSBL_ABUSEIPDB_ACTION", "SWW_WAF_DNSBL_ABUSEIPDB_ACTION",
} }
@@ -120,7 +120,7 @@ func (rq *request) limitPercentages() (percentage, percentage) {
} }
// The limits that lower requests and bytes alike. // The limits that lower requests and bytes alike.
common := lowest(otherTags, unknown, blocklisted, reputationListed) common := lowest(otherTags, unknown, blocklisted, dnsblAbuseIPDBListed)
return lowest(asnRequests, countryRequests, common), return lowest(asnRequests, countryRequests, common),
lowest(asnBytes, countryBytes, common) lowest(asnBytes, countryBytes, common)
+1 -1
View File
@@ -617,7 +617,7 @@ func TestObserveModeForwardsAClientADNSBLZoneDeniesAndAlertsIt(
} }
} }
func TestReputationLimitTakesPartInTheLowestPercentageOfEveryLimit( func TestDNSBLAbuseIPDBLimitTakesPartInTheLowestPercentageOfEveryLimit(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()