Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 3d7733bfaf The header size and the idle time as settings (closes #70)
check / check (push) Successful in 3m26s
SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES (default 32K) and
SWWAF_CLIENT_IDLE_TIMEOUT (default 120s) replace the two values the
proxy fixed. The idle time is read like the other durations, and can
be off.

Go's server reads 4K past the header limit it is given before it
refuses, so it is still given the setting less 4K. The header size
must be more than 4K and cannot be off; any other value stops the
start with a message that does not offer off.

SPEC.md and README.md say so. README.md lists both settings, no longer
calls them fixed, and names them as built.

Model: opus-5-5
2026-10-06 02:34:56 +00:00
4 changed files with 62 additions and 26 deletions
+14 -11
View File
@@ -13,15 +13,17 @@ JSON log line for every request.
Status: the first two milestones are built Status: the first two milestones are built
(https://git.eeqj.de/sneak/smallwebwaf/issues/13 and (https://git.eeqj.de/sneak/smallwebwaf/issues/13 and
https://git.eeqj.de/sneak/smallwebwaf/issues/14), and so are the static lists, https://git.eeqj.de/sneak/smallwebwaf/issues/14), and so are two parts of
which come next in the build order. `smallwebwaf` passes each request to the app milestone 3: the static lists, which come next in the build order, and the
and the app's answer back, unchanged, within its timeouts and size limits, works header size and the idle time as settings, which come last in it. `smallwebwaf`
out each client's address, refuses a client that sends too many requests, comes passes each request to the app and the app's answer back, unchanged, within its
from a country you refuse or from a network you refuse, lets the networks you timeouts and size limits, works out each client's address, refuses a client that
choose through, and writes a JSON log line for every request. It comes as the sends too many requests, comes from a country you refuse or from a network you
image the app's own image is built on. The rest of the design comes after that, refuse, lets the networks you choose through, and writes a JSON log line for
in the order of the build order in [`SPEC.md`](SPEC.md). The survey of existing every request. It comes as the image the app's own image is built on. The rest
tools that led to the design is in [`EVALUATION.md`](EVALUATION.md). of the design comes after that, in the order of the build order in
[`SPEC.md`](SPEC.md). The survey of existing tools that led to the design is in
[`EVALUATION.md`](EVALUATION.md).
## Getting started ## Getting started
@@ -542,8 +544,9 @@ so that they run in minimal containers.
## TODO ## TODO
- The rest of milestone 3, after the static lists, and the rest of the design, - The rest of milestone 3, from the bans that broken request limits lead to
in the order of the build order in [`SPEC.md`](SPEC.md). through the metrics endpoint, and the rest of the design, in the order of the
build order in [`SPEC.md`](SPEC.md).
## Documents ## Documents
+5 -2
View File
@@ -293,7 +293,8 @@ it.
needs: an alert destination, an account key, a token. needs: an alert destination, an account key, a token.
- Every setting's name starts with `SWWAF_`, since `smallwebwaf` shares its - Every setting's name starts with `SWWAF_`, since `smallwebwaf` shares its
container, and so its environment variables, with the app it protects. container, and so its environment variables, with the app it protects.
- Any limit or threshold can be switched off with the value `off`. - Any limit or threshold can be switched off with the value `off`, except
`SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`.
- A list set to an empty value is an empty list, and replaces the default. - A list set to an empty value is an empty list, and replaces the default.
- Every setting may instead be given as a file holding the value, named by the - Every setting may instead be given as a file holding the value, named by the
setting's name with `_FILE` added, such as `SWWAF_ADMIN_TOKEN_FILE`, for setting's name with `_FILE` added, such as `SWWAF_ADMIN_TOKEN_FILE`, for
@@ -413,7 +414,9 @@ The settings, by group:
headers, its body. headers, its body.
- `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` (default `32K`): the largest - `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` (default `32K`): the largest
request line and headers a client may send. Over it, `smallwebwaf` answers request line and headers a client may send. Over it, `smallwebwaf` answers
`431` and closes the connection, and nothing reaches the app. `431` and closes the connection, and nothing reaches the app. It must be
more than `4K`, and cannot be `off`: Go's HTTP server always has such a
limit, and reads 4 KiB past the one it is given before it refuses.
- `SWWAF_CLIENT_IDLE_TIMEOUT` (default `120s`): how long a kept-open - `SWWAF_CLIENT_IDLE_TIMEOUT` (default `120s`): how long a kept-open
connection may wait for its next request before `smallwebwaf` closes it. connection may wait for its next request before `smallwebwaf` closes it.
It is longer than the 90 seconds after which traefik, by default, closes a It is longer than the 90 seconds after which traefik, by default, closes a
+24 -9
View File
@@ -110,7 +110,7 @@ var (
errNotCountry = errors.New( errNotCountry = errors.New(
"is not a two-letter country code such as de or kp") "is not a two-letter country code such as de or kp")
errOnBothLists = errors.New("is in SWWAF_DENIED_COUNTRIES too") errOnBothLists = errors.New("is in SWWAF_DENIED_COUNTRIES too")
errNotOver4K = errors.New("must be more than 4K, and cannot be off") errNotOver4K = errors.New("is not a size of more than 4K, such as 32K")
) )
// FromEnvironment reads the settings with lookupEnv, normally // FromEnvironment reads the settings with lookupEnv, normally
@@ -123,7 +123,7 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
UpstreamURL: env.appURL("SWWAF_UPSTREAM_URL", "http://127.0.0.1:8081"), UpstreamURL: env.appURL("SWWAF_UPSTREAM_URL", "http://127.0.0.1:8081"),
TrustedProxies: env.netblocks("SWWAF_TRUSTED_PROXIES", privateRanges), TrustedProxies: env.netblocks("SWWAF_TRUSTED_PROXIES", privateRanges),
ClientRequestTimeout: env.duration("SWWAF_CLIENT_REQUEST_TIMEOUT", "60s"), ClientRequestTimeout: env.duration("SWWAF_CLIENT_REQUEST_TIMEOUT", "60s"),
ClientRequestHeaderMaxBytes: env.size( ClientRequestHeaderMaxBytes: env.headerSize(
"SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES", "32K"), "SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES", "32K"),
ClientIdleTimeout: env.duration("SWWAF_CLIENT_IDLE_TIMEOUT", "120s"), ClientIdleTimeout: env.duration("SWWAF_CLIENT_IDLE_TIMEOUT", "120s"),
ClientResponseTimeout: env.duration("SWWAF_CLIENT_RESPONSE_TIMEOUT", "30m"), ClientResponseTimeout: env.duration("SWWAF_CLIENT_RESPONSE_TIMEOUT", "30m"),
@@ -142,13 +142,6 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""), "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
} }
// Go's server reads 4K past the limit it is given on the request line
// and headers before it refuses them, so proxy.New gives it this
// setting less 4K, which must leave a limit.
if cfg.ClientRequestHeaderMaxBytes <= 4*kibibyte {
env.check("SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES", errNotOver4K)
}
for _, country := range cfg.ExclusivelyAllowedCountries { for _, country := range cfg.ExclusivelyAllowedCountries {
if slices.Contains(cfg.DeniedCountries, country) { if slices.Contains(cfg.DeniedCountries, country) {
env.check("SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", env.check("SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES",
@@ -243,6 +236,15 @@ func (e *environment) size(name, defaultValue string) int64 {
return size return size
} }
// headerSize reads the setting that is the largest request line and
// headers.
func (e *environment) headerSize(name, defaultValue string) int64 {
size, err := parseHeaderSize(e.value(name, defaultValue))
e.check(name, err)
return size
}
// count reads a setting that is a number of requests. // count reads a setting that is a number of requests.
func (e *environment) count(name, defaultValue string) int64 { func (e *environment) count(name, defaultValue string) int64 {
count, err := parseCount(e.value(name, defaultValue)) count, err := parseCount(e.value(name, defaultValue))
@@ -314,6 +316,19 @@ func parseSize(value string) (int64, error) {
return n * unit, nil return n * unit, nil
} }
// parseHeaderSize reads the largest request line and headers: a size as
// parseSize reads it, but more than 4K and never off. Go's server reads 4K
// past the limit it is given before it refuses, so proxy.New gives it this
// size less 4K, which must leave a limit.
func parseHeaderSize(value string) (int64, error) {
size, err := parseSize(value)
if err != nil || size <= 4*kibibyte {
return 0, fmt.Errorf("%q %w", value, errNotOver4K)
}
return size, nil
}
// splitUnit splits a size into its number and the bytes its suffix // splitUnit splits a size into its number and the bytes its suffix
// stands for. // stands for.
func splitUnit(value string) (string, int64) { func splitUnit(value string) (string, int64) {
+19 -4
View File
@@ -185,13 +185,31 @@ func TestSizesAndOff(t *testing.T) {
func TestRequestHeaderMaxBytesJustOver4K(t *testing.T) { func TestRequestHeaderMaxBytesJustOver4K(t *testing.T) {
t.Parallel() t.Parallel()
// 4K and off stop the start, as TestInvalidValueStopsTheStart shows.
cfg := fromEnvironment(t, environment{clientHeaderMaxBytes: "4097"}) cfg := fromEnvironment(t, environment{clientHeaderMaxBytes: "4097"})
if cfg.ClientRequestHeaderMaxBytes != 4097 { if cfg.ClientRequestHeaderMaxBytes != 4097 {
t.Errorf("4097 read as %d", cfg.ClientRequestHeaderMaxBytes) t.Errorf("4097 read as %d", cfg.ClientRequestHeaderMaxBytes)
} }
} }
func TestRequestHeaderMaxBytesRefusalNeverOffersOff(t *testing.T) {
t.Parallel()
for _, value := range []string{"32KB", "0", "4K", off} {
t.Run(value, func(t *testing.T) {
t.Parallel()
_, err := config.FromEnvironment(
environment{clientHeaderMaxBytes: value}.lookupEnv)
want := clientHeaderMaxBytes + `: "` + value +
`" is not a size of more than 4K, such as 32K`
if err == nil || err.Error() != want {
t.Errorf("error %v, want %s", err, want)
}
})
}
}
func TestRateLimitsOff(t *testing.T) { func TestRateLimitsOff(t *testing.T) {
t.Parallel() t.Parallel()
@@ -242,9 +260,6 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
{denyNets, "198.51.100.0/24,"}, {denyNets, "198.51.100.0/24,"},
{clientRequestTimeout, "60"}, {clientRequestTimeout, "60"},
{clientRequestTimeout, ""}, {clientRequestTimeout, ""},
{clientHeaderMaxBytes, "4K"},
{clientHeaderMaxBytes, off},
{clientHeaderMaxBytes, "32KB"},
{clientIdleTimeout, "0s"}, {clientIdleTimeout, "0s"},
{clientIdleTimeout, "2 minutes"}, {clientIdleTimeout, "2 minutes"},
{clientResponseTimeout, "1y"}, {clientResponseTimeout, "1y"},