Compare commits

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

Go's server reads 4K past the header limit it is given before it
refuses, so it is still given the setting less 4K, and a header size
of 4K or less, or off, stops the start. The idle time can be off.

README.md lists both settings and no longer calls them fixed.

Model: opus-5-5
2026-10-06 02:13:40 +00:00
4 changed files with 26 additions and 62 deletions
+11 -14
View File
@@ -13,17 +13,15 @@ JSON log line for every request.
Status: the first two milestones are built
(https://git.eeqj.de/sneak/smallwebwaf/issues/13 and
https://git.eeqj.de/sneak/smallwebwaf/issues/14), and so are two parts of
milestone 3: the static lists, which come next in the build order, and the
header size and the idle time as settings, which come last in it. `smallwebwaf`
passes each request to the app and the app's answer back, unchanged, within its
timeouts and size limits, works out each client's address, refuses a client that
sends too many requests, comes from a country you refuse or from a network you
refuse, lets the networks you choose through, and writes a JSON log line for
every request. It comes as the image the app's own image is built on. The rest
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).
https://git.eeqj.de/sneak/smallwebwaf/issues/14), and so are the static lists,
which come next in the build order. `smallwebwaf` passes each request to the app
and the app's answer back, unchanged, within its timeouts and size limits, works
out each client's address, refuses a client that sends too many requests, comes
from a country you refuse or from a network you refuse, lets the networks you
choose through, and writes a JSON log line for every request. It comes as the
image the app's own image is built on. The rest 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
@@ -544,9 +542,8 @@ so that they run in minimal containers.
## TODO
- The rest of milestone 3, from the bans that broken request limits lead to
through the metrics endpoint, and the rest of the design, in the order of the
build order in [`SPEC.md`](SPEC.md).
- The rest of milestone 3, after the static lists, and the rest of the design,
in the order of the build order in [`SPEC.md`](SPEC.md).
## Documents
+2 -5
View File
@@ -293,8 +293,7 @@ it.
needs: an alert destination, an account key, a token.
- Every setting's name starts with `SWWAF_`, since `smallwebwaf` shares its
container, and so its environment variables, with the app it protects.
- Any limit or threshold can be switched off with the value `off`, except
`SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`.
- Any limit or threshold can be switched off with the value `off`.
- 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
setting's name with `_FILE` added, such as `SWWAF_ADMIN_TOKEN_FILE`, for
@@ -414,9 +413,7 @@ The settings, by group:
headers, its body.
- `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` (default `32K`): the largest
request line and headers a client may send. Over it, `smallwebwaf` answers
`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.
`431` and closes the connection, and nothing reaches the app.
- `SWWAF_CLIENT_IDLE_TIMEOUT` (default `120s`): how long a kept-open
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
+9 -24
View File
@@ -110,7 +110,7 @@ var (
errNotCountry = errors.New(
"is not a two-letter country code such as de or kp")
errOnBothLists = errors.New("is in SWWAF_DENIED_COUNTRIES too")
errNotOver4K = errors.New("is not a size of more than 4K, such as 32K")
errNotOver4K = errors.New("must be more than 4K, and cannot be off")
)
// 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"),
TrustedProxies: env.netblocks("SWWAF_TRUSTED_PROXIES", privateRanges),
ClientRequestTimeout: env.duration("SWWAF_CLIENT_REQUEST_TIMEOUT", "60s"),
ClientRequestHeaderMaxBytes: env.headerSize(
ClientRequestHeaderMaxBytes: env.size(
"SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES", "32K"),
ClientIdleTimeout: env.duration("SWWAF_CLIENT_IDLE_TIMEOUT", "120s"),
ClientResponseTimeout: env.duration("SWWAF_CLIENT_RESPONSE_TIMEOUT", "30m"),
@@ -142,6 +142,13 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
"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 {
if slices.Contains(cfg.DeniedCountries, country) {
env.check("SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES",
@@ -236,15 +243,6 @@ func (e *environment) size(name, defaultValue string) int64 {
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.
func (e *environment) count(name, defaultValue string) int64 {
count, err := parseCount(e.value(name, defaultValue))
@@ -316,19 +314,6 @@ func parseSize(value string) (int64, error) {
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
// stands for.
func splitUnit(value string) (string, int64) {
+4 -19
View File
@@ -185,31 +185,13 @@ func TestSizesAndOff(t *testing.T) {
func TestRequestHeaderMaxBytesJustOver4K(t *testing.T) {
t.Parallel()
// 4K and off stop the start, as TestInvalidValueStopsTheStart shows.
cfg := fromEnvironment(t, environment{clientHeaderMaxBytes: "4097"})
if cfg.ClientRequestHeaderMaxBytes != 4097 {
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) {
t.Parallel()
@@ -260,6 +242,9 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
{denyNets, "198.51.100.0/24,"},
{clientRequestTimeout, "60"},
{clientRequestTimeout, ""},
{clientHeaderMaxBytes, "4K"},
{clientHeaderMaxBytes, off},
{clientHeaderMaxBytes, "32KB"},
{clientIdleTimeout, "0s"},
{clientIdleTimeout, "2 minutes"},
{clientResponseTimeout, "1y"},