1 Commits
Author SHA1 Message Date
sneak de36f24ed5 README and SPEC say a client too slow to send its request line and headers may get 400 (closes #138)
check / check (push) Canceled after 0s
A client too slow to send its request line and headers within
SWWAF_CLIENT_REQUEST_TIMEOUT gets either no answer or, depending on how
much of them had arrived, 400 Bad Request from Go's server; either way
its connection is closed, nothing reaches the app, and no request log
line is written. The README's "Request log" and SPEC's size and time
limits said such a client always gets no answer. Both documents'
summaries of smallwebwaf's own answers now cover only limits passed
after the request line and headers have been read, and give 408 only
for a slow body; SPEC names Go's server as the one answering 431.

Model: opus-5-5
2026-10-20 12:39:48 +00:00
13 changed files with 69 additions and 459 deletions
+35 -49
View File
@@ -96,14 +96,11 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
## What it does so far
- Passes each request to the app and the app's answer back unchanged: method,
path, query, headers, body and status. The one exception is a path starting
with `//` that holds a character Go escapes, such as `|`: Go cannot send it as
it is, and the app receives it decoded and escaped again, `%2F` as `/`. Bodies
stream through in both directions and are never held whole in memory, but for
the part of a request body the Core Rule Set reads, at most
`SWWAF_WAF_BODY_LIMIT` and one byte more, which is held until the app is sent
it. A WebSocket, or any other upgraded connection, passes through, and the
timeouts do not cut it.
path, query, headers, body and status. Bodies stream through in both
directions and are never held whole in memory, but for the part of a request
body the Core Rule Set reads, at most `SWWAF_WAF_BODY_LIMIT` and one byte
more, which is held until the app is sent it. A WebSocket, or any other
upgraded connection, passes through, and the timeouts do not cut it.
- Works out the client's address. A TCP peer outside `SWWAF_TRUSTED_PROXIES` is
the client, and the forwarded headers it sends are replaced, not passed on.
For a peer inside it, `X-Forwarded-For` is read from the right, and the first
@@ -1047,9 +1044,10 @@ headers before `smallwebwaf` sees the request, and some requests end there,
without a line in the log: headers over `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`,
which it answers `431`, and requests it cannot read at all, which it answers
itself, mostly with `400`. A client too slow to send its request line and
headers within `SWWAF_CLIENT_REQUEST_TIMEOUT` gets either no answer or an error
answer from the server, mostly `400 Bad Request`; either way the server closes
its connection, nothing reaches the app, and no line is written in the log.
headers within `SWWAF_CLIENT_REQUEST_TIMEOUT` gets either no answer or,
depending on how much of them had arrived, `400 Bad Request` from the server;
either way the server closes its connection, nothing reaches the app, and no
line is written in the log.
### Sending the log to a syslog server
@@ -1449,12 +1447,11 @@ whole service, its `asn`, for an AS number, its `name`, for a named netblock, or
the `start` of a window in which it has requests or bytes; a list's `url`,
`fetched` or `lines`, which is `[]` for an empty list; a verdict's `zone`,
`client`, `listed`, which is `false` for a client the zone does not list, or
`fetched`. So does a ban whose `expires` is not after its `start` or whose
`cause` is not `limit`, `attack`, `admin` or `crowdsec`, alerts waiting for a
destination that is not `webhook`, `slack` or `ntfy`, an anomaly counter whose
`scope` is not `client`, `net`, `asn`, `total` or `watch`, and a copy of a list
with a line that would make its fetch fail. An answer's `asn` or `as_name` left
out reads as empty.
`fetched`. So does a ban whose `cause` is not `limit`, `attack`, `admin` or
`crowdsec`, alerts waiting for a destination that is not `webhook`, `slack` or
`ntfy`, an anomaly counter whose `scope` is not `client`, `net`, `asn`, `total`
or `watch`, and a copy of a list with a line that would make its fetch fail. An
answer's `asn` or `as_name` left out reads as empty.
While it runs, `smallwebwaf` watches `SWWAF_STATE_DIR` and takes in your edit of
a state file as soon as you save it: what the file then holds replaces what
@@ -1464,15 +1461,14 @@ writes a file it takes in any edit made since, so your edit is not overwritten;
a change `smallwebwaf` made after you opened the file, such as a new ban, is
lost when you save over it. An edit that would stop the start, because it does
not parse, has another `version`, leaves out a field an entry needs, gives a ban
an `expires` not after its `start` or another `cause`, names another
destination, gives an anomaly counter another `scope` or gives a list's copy a
line that would make its fetch fail, does not stop the running `smallwebwaf`: it
keeps what it holds, and at the file's next write renames your file to
`<name>.bad`, such as `bans.json.bad`, writes the file again from memory, logs
the file and where the error is, and raises a `file_error` alert for it. It
waits for that write because an editor's file can be read before the editor has
finished writing it. Mend the `.bad` file and move it back. A file you remove is
written again at its next write.
another `cause`, names another destination, gives an anomaly counter another
`scope` or gives a list's copy a line that would make its fetch fail, does not
stop the running `smallwebwaf`: it keeps what it holds, and at the file's next
write renames your file to `<name>.bad`, such as `bans.json.bad`, writes the
file again from memory, logs the file and where the error is, and raises a
`file_error` alert for it. It waits for that write because an editor's file can
be read before the editor has finished writing it. Mend the `.bad` file and move
it back. A file you remove is written again at its next write.
To ban a netblock, add an entry to `bans.json` with its `netblock`, its `start`
and its `expires`, `null` for a ban that never ends; its `reason` and its
@@ -1961,14 +1957,8 @@ main "$@"
without one, it still starts. At each start the `run` script of `smallwebwaf`
gives that directory and every file in it to the `smallwebwaf` user, so a host
directory mounted there needs no change of owner.
- `docker stop` has runit stop both processes at once, each with SIGTERM.
`smallwebwaf` then stops taking requests and gives those in progress five
seconds to finish, which they can do only while the app still answers. An app
that exits at once on SIGTERM ends them: the client gets `502`, or a response
cut short if the app had already begun to answer. An app that should let them
finish keeps taking and answering requests, not only the ones it already has,
for the whole five seconds after SIGTERM, since `smallwebwaf` can still pass
it requests it took before then.
- `docker stop` has runit stop both processes. `smallwebwaf` then stops taking
requests and gives those in progress five seconds to finish.
A rule file is one rule per line: a name, what to match against, what to do, and
a regex.
@@ -2160,14 +2150,13 @@ addresses sends, so that one client costs at most one check every
`SWWAF_REPUTATION_CACHE_TTL`.
Only a client whose history counts an offence is checked, so that the checks are
spent on suspects: so far, one that has broken a rate limit, a byte limit or the
error burst, matched a ban rule, asked for a trap path, or had a request refused
by a block rule, by the Core Rule Set or for a missing or wrong token. A client
dropped from the table of clients loses its history, and with it its offences. A
client is checked in the background, at its first request after its offence that
reaches the check: no request waits, a request refused under its ban is not
checked, and the request that has it checked, and any other from it before the
answer comes, goes on as from a client without a score.
spent on suspects: so far, one that has broken a rate limit or a byte limit,
matched a ban rule, or had a request refused by a block rule or the Core Rule
Set. A client dropped from the table of clients loses its history, and with it
its offences. A client is checked in the background, at its first request after
its offence that reaches the check: no request waits, a request refused under
its ban is not checked, and the request that has it checked, and any other from
it before the answer comes, goes on as from a client without a score.
A score at or over `SWWAF_ABUSEIPDB_MIN_SCORE`, 75 by default, is a hit, and
`SWWAF_REPUTATION_ACTION` says what is done with its client, as for a DNSBL
@@ -2384,13 +2373,10 @@ so that they run in minimal containers.
that the health check passes, that a request reaches the app through
`smallwebwaf`, that a second request in a minute bans the client, that a probe
for `/.env` bans another client, whose next request makes the ban permanent,
that `sv stop` and `docker stop` stop it in order, that a new container on the
same volume still refuses the banned client, that a container with its state
files on a new volume mounted at the directory `SWWAF_STATE_DIR_FILE` names
becomes healthy, and that one whose `SWWAF_STATE_DIR` does not exist logs
`smallwebwaf`'s message about it; then removes the containers, the volumes and
both images. It needs network access, for nixpkgs' binary cache, and
`script/check` does not run it; `make example-app` does.
that `sv stop` and `docker stop` stop it in order, and that a new container on
the same volume still refuses the banned client; then removes the containers,
the volume and both images. It needs network access, for nixpkgs' binary
cache, and `script/check` does not run it; `make example-app` does.
- `script/gitea-requests`: builds the image and runs gitea 1.27.3 behind it,
with every setting of `smallwebwaf` at its default but `SWWAF_UPSTREAM_URL`,
which names gitea's container. Of the requests that three of the notes
+11 -17
View File
@@ -225,9 +225,9 @@ address or one /64, so a permanent ban does not reach neighbours who did
nothing.
An offence is a request `smallwebwaf` holds against the client: one that carries
a clear sign of attack, breaks a limit, or is refused by a rule file, by the
Core Rule Set or for a missing or wrong token. Offences are counted by kind in
the client's history. Two kinds lead to a ban, each by its own rule:
a clear sign of attack, breaks a limit, or is refused by a rule file or the Core
Rule Set. Offences are counted by kind in the client's history. Two kinds lead
to a ban, each by its own rule:
- A clear sign of attack: a request that matches a rule file rule whose action
is `ban`, or asks for a path in `SWWAF_TRAP_PATHS`. Examples are a probe for a
@@ -452,10 +452,11 @@ The settings, by group:
- Go's HTTP server, on which `smallwebwaf` is built, reads a request's line
and headers before `smallwebwaf` sees the request. A client that takes
longer than `SWWAF_CLIENT_REQUEST_TIMEOUT` to send them gets either no
answer or an error answer from the server, mostly `400 Bad Request`;
either way the server closes its connection and nothing reaches the app.
Headers over `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` are answered `431` by
the server itself. Neither request gets a line in the request log.
answer or, depending on how much of them had arrived, `400 Bad Request`
from the server; either way the server closes its connection and nothing
reaches the app. Headers over `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` are
answered `431` by the server itself. Neither request gets a line in the
request log.
- A WebSocket connection leaves these limits behind once it is upgraded: it
stays open until either side closes it.
- Lookup of AS number and country (R7). On by default through GeoJS, which needs
@@ -1326,16 +1327,9 @@ The two processes:
- A `smallwebwaf` that stops at start, for example on invalid configuration, is
started again every second and stops again, logging its message each time,
until the cause is fixed. The health check fails meanwhile.
- `docker stop` sends SIGTERM to `runsvinit`, which has runit stop both services
at once, each with SIGTERM, and exits once they have stopped. `smallwebwaf`
stops taking requests, gives those in progress five seconds to finish, and
writes its state files as it stops (see "Persistent state"). The requests in
progress can finish only while the app still answers. An app that exits at
once on SIGTERM ends them: the client gets `502`, or a response cut short if
the app had already begun to answer. An app that should let them finish keeps
taking and answering requests, not only the ones it already has, for the whole
five seconds after SIGTERM, since `smallwebwaf` can still pass it requests it
took before then.
- `docker stop` sends SIGTERM to `runsvinit`, which has runit stop both
services, each with SIGTERM, and exits once they have stopped. `smallwebwaf`
writes its state files as it stops (see "Persistent state").
- The container's root filesystem stays writable: runit writes each service's
status into its directory under `/etc/service`.
+2 -15
View File
@@ -1,10 +1,8 @@
package proxy
import (
"bufio"
"errors"
"io"
"net"
"net/http"
"sync/atomic"
@@ -213,20 +211,9 @@ func (w *responseWriter) FlushError() error {
return err
}
// Hijack takes over the connection to the client, which ReverseProxy does
// only to pass on the app's 101 Switching Protocols: that is then the
// status sent.
func (w *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
conn, buffered, err := http.NewResponseController(w.ResponseWriter).Hijack()
if err == nil {
w.status = http.StatusSwitchingProtocols
}
return conn, buffered, err
}
// Unwrap lets http.ResponseController reach net/http's own
// ResponseWriter for what responseWriter does not do itself.
// ResponseWriter, which is how ReverseProxy takes over the connection of
// an upgraded request.
func (w *responseWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
+1 -1
View File
@@ -23,7 +23,7 @@ import (
// limit while the Core Rule Set reads it, which it notes nothing of.
func (rq *request) checkCoreRuleSet() string {
cfg := rq.h.config
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) {
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in.URL, cfg.WAFExemptPaths) {
return ""
}
-88
View File
@@ -74,47 +74,6 @@ func TestPassesRequestAndAnswerUnchanged(t *testing.T) {
wantRequestFields(t, line, addr, len(requestBody), len(answerBody))
}
func TestPassesThePathAsSent(t *testing.T) {
t.Parallel()
for _, path := range []string{
// An encoded slash and a character Go escapes: |, ^, and é sent
// as its UTF-8 bytes.
"/a%2Fb|c", "/feat%2Fx/file^1", "/x%2Fy/café",
// A path starting with //, which Go would send as a scheme and
// host if it were put in URL.Opaque.
"//a%2Fb",
} {
t.Run(path, func(t *testing.T) {
t.Parallel()
saw := make(chan string, 1)
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
saw <- r.RequestURI
})
addr, out := startProxy(t, app.URL, nil)
conn := dial(t, addr)
send(t, conn, "GET "+path+"?q=1 HTTP/1.1\r\nHost: "+appHost+"\r\n\r\n")
got := readResponse(t, conn)
if got.status != http.StatusOK {
t.Fatalf("status %d, want %d", got.status, http.StatusOK)
}
target := <-saw
if target != path+"?q=1" {
t.Errorf("app saw %s, want %s", target, path+"?q=1")
}
line := out.requestLine(t)
if line.Path != path {
t.Errorf("log line has path %s, want %s", line.Path, path)
}
})
}
}
// wantAppSaw checks that the app received the test's request unchanged.
func wantAppSaw(t *testing.T, saw appSaw, body []byte) {
t.Helper()
@@ -192,36 +151,6 @@ func wantRequestFields(t *testing.T, line logLine, host string, sent, received i
}
}
func TestAddsNoContentTypeTheAppDidNotSend(t *testing.T) {
t.Parallel()
const typed = "text/plain"
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/typed" {
w.Header().Set("Content-Type", typed)
} else {
// The key with no value keeps Go's server from adding one.
w.Header()["Content-Type"] = nil
}
_, _ = io.WriteString(w, "<!doctype html><title>page</title>")
})
addr, _ := startProxy(t, app.URL, nil)
got := get(t, addr, "/")
if got.header.Values("Content-Type") != nil {
t.Errorf("client got Content-Type %q, want none",
got.header.Get("Content-Type"))
}
got = get(t, addr, "/typed")
if got.header.Get("Content-Type") != typed {
t.Errorf("client got Content-Type %q, want %q",
got.header.Get("Content-Type"), typed)
}
}
func TestStreamsTheRequestBody(t *testing.T) {
t.Parallel()
@@ -380,23 +309,6 @@ func echoAfterUpgrade(w http.ResponseWriter, r *http.Request) {
}
}
func TestAnswers502WhenTheAppSwitchesToAnotherProtocol(t *testing.T) {
t.Parallel()
// answerAfterUpgrade switches to websocket whatever the client asked
// for, and ReverseProxy passes on a switch only to the protocol the
// client asked for, here "websocket, foo".
app := startApp(t, answerAfterUpgrade)
addr, out := startProxy(t, app.URL, nil)
conn := dial(t, addr)
send(t, conn, "GET /ws HTTP/1.1\r\nHost: app\r\n"+
"Connection: Upgrade\r\nUpgrade: websocket, foo\r\n\r\n")
wantStatus(t, readResponse(t, conn), http.StatusBadGateway)
wantLine(t, out.requestLine(t), http.StatusBadGateway, requestlog.ActionUpstreamError)
}
func TestServerHasTheDefaultLimits(t *testing.T) {
t.Parallel()
-3
View File
@@ -185,9 +185,6 @@ func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
"/assets/x%2Fy",
"/assets/x%2fy",
`/assets/x\y`,
// The same with a character Go escapes, |, for which Go's
// URL.EscapedPath gives /assets/x/y%7Cz, with no encoded slash.
"/assets/x%2Fy|z",
} {
t.Run(sent, func(t *testing.T) {
t.Parallel()
+9 -45
View File
@@ -8,6 +8,7 @@ import (
"net/http/httptrace"
"net/http/httputil"
"net/netip"
"net/url"
"os"
"slices"
"strings"
@@ -21,7 +22,6 @@ import (
"sneak.berlin/go/smallwebwaf/internal/lookup"
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// flushAfterEachWrite has ReverseProxy pass on each part of the app's
@@ -137,7 +137,7 @@ func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
Method: r.Method,
Scheme: scheme(r, peerTrusted),
Host: r.Host,
Path: rules.Path(r),
Path: r.URL.EscapedPath(),
Query: r.URL.RawQuery,
Protocol: r.Proto,
Referer: r.Referer(),
@@ -284,7 +284,7 @@ func (rq *request) checkClient(ctx context.Context) string {
}
rq.counted = !isInside(rq.client, cfg.RateLimitExemptNets) &&
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
!pathExempt(rq.in.URL, cfg.RateLimitExemptPaths)
if rq.counted {
rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
@@ -307,7 +307,7 @@ func (rq *request) checkClient(ctx context.Context) string {
return rq.checkCoreRuleSet()
}
// pathExempt reports whether r is exempt under prefixes,
// pathExempt reports whether a request for u is exempt under prefixes,
// SWWAF_RATE_LIMIT_EXEMPT_PATHS or SWWAF_WAF_EXEMPT_PATHS: whether its
// path as sent, the path the app receives, not percent-decoded, starts
// with one of prefixes, so that /%61ssets/x is not under /assets/ for an
@@ -316,9 +316,10 @@ func (rq *request) checkClient(ctx context.Context) string {
// encoded slash (%2F or %2f), never is, since an app may act on it as a
// path outside every prefix: /assets/..%2Flogin as /login, or /assets%2Fx
// as one path segment, as Go's router does.
func pathExempt(r *http.Request, prefixes []string) bool {
decoded := r.URL.Path
sent := rules.Path(r)
func pathExempt(u *url.URL, prefixes []string) bool {
decoded := u.Path
// EscapedPath is the path as the app receives it, not decoded.
sent := u.EscapedPath()
if strings.Contains(decoded, "..") || strings.Contains(decoded, `\`) ||
strings.Contains(strings.ToLower(sent), "%2f") {
@@ -374,17 +375,6 @@ func (rq *request) rewrite(pr *httputil.ProxyRequest) {
// ReverseProxy drops query parameters it cannot parse; the app gets
// the query as the client sent it.
pr.Out.URL.RawQuery = pr.In.URL.RawQuery
// ReverseProxy sends the path URL.EscapedPath gives, which, for a path
// holding a character Go escapes, such as |, is the whole path decoded
// and escaped again, %2F turned into /. Opaque is sent as it is, so the
// app gets the path as the client sent it. Left out are a target that
// is no path, such as *, and a path starting with //, which Go would
// send in Opaque as a scheme and host.
path := rules.Path(pr.In)
if strings.HasPrefix(path, "/") && !strings.HasPrefix(path, "//") {
pr.Out.URL.Opaque = path
}
setForwardedHeaders(pr.In, pr.Out, rq.peer, rq.peerTrusted)
pr.Out.Header.Set(requestIDHeader, rq.line.RequestID)
pr.Out.Header.Del(asnHeader)
@@ -405,10 +395,8 @@ func (rq *request) modifyResponse(res *http.Response) error {
// timeouts. ReverseProxy writes this answer straight to the
// connection it takes over, not through rq.out, and then copies
// what passes each way through res.Body, the connection to the app.
// It may still refuse the switch, as when the app's Upgrade is not
// the client's, so the status is noted only once it has taken over
// the connection, by rq.out's Hijack.
rq.stopTimers()
rq.out.status = res.StatusCode
rq.line.Websocket = true
conn, ok := res.Body.(io.ReadWriteCloser)
@@ -431,13 +419,6 @@ func (rq *request) modifyResponse(res *http.Response) error {
return errResponseTooLarge
}
// Go's server adds a Content-Type it guesses from the body to a
// response whose headers have no Content-Type key. The key with no
// value sends none, as the app sent none.
if res.Header.Get("Content-Type") == "" {
rq.out.Header()["Content-Type"] = nil
}
res.Body = &responseBody{body: limitBody(res.Body, maxBytes), rq: rq}
rq.startClientResponseTimeout()
@@ -447,13 +428,6 @@ func (rq *request) modifyResponse(res *http.Response) error {
// answerError is ReverseProxy's ErrorHandler: the request could not be
// passed to the app, or the app's answer cannot be passed on.
func (rq *request) answerError(_ http.ResponseWriter, _ *http.Request, err error) {
if rq.upgraded != nil {
// The app switched protocols, but the switch was not passed on,
// and ReverseProxy leaves the app's connection open when it
// refuses it.
_ = rq.upgraded.Close()
}
refused := rq.refused.Load()
if refused == nil {
if rq.in.Context().Err() != nil {
@@ -736,16 +710,6 @@ func (rq *request) requestTimedOut(limit string) {
}
if rq.body == nil || !rq.body.waiting.Load() {
// Once the app has answered, the transport, ReverseProxy and Go's
// server each close the client's body, and closing it reads what
// is left of a body under 256 KiB without requestBody.Read, so
// waiting is false while that read waits for the client. That
// read ends now, before refuse: should smallwebwaf still answer
// itself, answer then sets the deadline it gives its own answers.
if !rq.answerStarted.IsZero() {
rq.stopReadingBody(time.Now())
}
rq.refuse(refusal{
status: http.StatusGatewayTimeout,
action: requestlog.ActionTimedOut,
-55
View File
@@ -1,55 +0,0 @@
package proxy
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"sneak.berlin/go/smallwebwaf/internal/config"
)
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
rq := &request{
h: &handler{config: &config.Config{}},
out: &responseWriter{ResponseWriter: w},
}
app := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{},
Body: http.NoBody,
}
err := rq.modifyResponse(app)
if err != nil {
t.Errorf("modifyResponse: %v", err)
}
// The headers go out with this, the first part of the body,
// which Go's server would guess a Content-Type from.
_, _ = io.WriteString(w, "<!doctype html><title>page</title>")
}))
t.Cleanup(srv.Close)
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL,
http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
res, err := srv.Client().Do(req)
if err != nil {
t.Fatalf("get: %v", err)
}
_ = res.Body.Close()
if res.Header.Values("Content-Type") != nil {
t.Errorf("client got Content-Type %q, want none",
res.Header.Get("Content-Type"))
}
}
-92
View File
@@ -7,7 +7,6 @@ import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
@@ -249,97 +248,6 @@ func TestClientStopsSendingAfterTheAppHasAnswered(t *testing.T) {
wantLimitHits(t, addr, clientRequestTimeout, 1)
}
func TestClientStopsSendingWithLittleOfItsBodyLeft(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
// limit is the setting set to shortTimeout, which runs out, and
// clientTimeout what SWWAF_CLIENT_REQUEST_TIMEOUT is set to when it
// is not limit.
limit, clientTimeout string
}{
{
name: "client request timeout",
limit: clientRequestTimeout,
},
{
name: "upstream request timeout, client request timeout off",
limit: upstreamRequestTimeout,
clientTimeout: off,
},
{
name: "upstream request timeout, client request timeout longer",
limit: upstreamRequestTimeout,
clientTimeout: longTimeoutSetting,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// appClosed is closed once smallwebwaf has closed its
// connection to the app, which it does once it has the app's
// answer.
appClosed := make(chan struct{})
// The app answers without reading the rest of the body, and
// says it closes the connection, only once smallwebwaf waits
// for the client to send more: it takes the bytes the client
// sent, and then waits half the timeout, far longer than
// smallwebwaf takes to go on to wait.
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
conn, buf, err := http.NewResponseController(w).Hijack()
if err != nil {
return
}
_, _ = io.ReadFull(buf.Reader, make([]byte, len("the first bytes")))
time.Sleep(shortTimeout / 2)
_, _ = buf.WriteString("HTTP/1.1 404 Not Found\r\n" +
"Content-Length: 0\r\nConnection: close\r\n\r\n")
_ = buf.Flush()
_, _ = io.Copy(io.Discard, buf.Reader)
_ = conn.Close()
close(appClosed)
})
env := map[string]string{tc.limit: shortTimeoutSetting, metricsToken: token}
if tc.clientTimeout != "" {
env[clientRequestTimeout] = tc.clientTimeout
}
addr, out := startProxy(t, app.URL, env)
// Less than 256 KiB of the body is left after the first bytes,
// little enough for Go's server in smallwebwaf to read the rest
// itself before it sends the app's answer.
start := time.Now()
conn := dial(t, addr)
send(t, conn, "POST /upload HTTP/1.1\r\nHost: app\r\nContent-Length: 200000\r\n\r\n"+
"the first bytes")
select {
case <-appClosed:
case <-time.After(waitLimit):
t.Fatal("smallwebwaf did not close its connection to the app")
}
// More of the body, which can no longer go to the app, and then
// nothing.
send(t, conn, strings.Repeat("x", 8<<10))
got := readResponse(t, conn)
wantTimedOut(t, start)
wantStatus(t, got, http.StatusNotFound)
wantLine(t, out.requestLine(t), http.StatusNotFound, requestlog.ActionTimedOut)
wantLimitHits(t, addr, tc.limit, 1)
})
}
}
func TestAppTooSlowToAnswer(t *testing.T) {
t.Parallel()
+2 -7
View File
@@ -61,7 +61,6 @@ var (
// errMissing is for an entry without a field it needs.
errMissing = errors.New("has no")
errCause = errors.New("is not limit, attack, admin or crowdsec")
errExpires = errors.New("is not after its start")
errDestination = errors.New("is not webhook, slack or ntfy")
errScope = errors.New("is not client, net, asn, total or watch")
errWaitingList = errors.New(`waiting is a list, but now lists the alerts by ` +
@@ -647,10 +646,8 @@ func (e BanEntry) ban() bans.Ban {
// client, a start, from which the length of the netblock's next ban is
// worked out, or an expires, which would make it permanent. A permanent
// ban's expires is null, which Bans cannot tell from a missing one, so
// each expires is read again as written. An expires not after the start
// is refused too, since the netblock's next ban for a broken limit, three
// times as long as this one, would then refuse nothing; and so is a cause
// other than limit, attack, admin or crowdsec, most likely misspelt.
// each expires is read again as written. A cause other than limit,
// attack, admin or crowdsec, most likely misspelt, is refused too.
func (f *bansFile) check(data []byte) error {
var written struct {
Bans []struct {
@@ -671,8 +668,6 @@ func (f *bansFile) check(data []byte) error {
return missing(i, "start")
case written.Bans[i].Expires == nil:
return missing(i, "expires")
case entry.Expires != nil && !entry.Expires.After(entry.Start):
return fmt.Errorf("entry %d's expires %w", i+1, errExpires)
case entry.Cause != "" && entry.Cause != bans.CauseLimit &&
entry.Cause != bans.CauseAttack && entry.Cause != bans.CauseAdmin &&
entry.Cause != bans.CauseCrowdSec:
-15
View File
@@ -865,21 +865,6 @@ func TestBanWithAnotherCauseStopsTheStart(t *testing.T) {
`: entry 4's cause "atack" is not limit, attack, admin or crowdsec`)
}
func TestBanThatDoesNotEndAfterItsStartStopsTheStart(t *testing.T) {
t.Parallel()
// The first ban ends a second after its start; the second ends as it
// starts, or an hour before.
for _, expires := range []string{`"2026-10-06T00:00:00Z"`, `"2026-10-05T23:00:00Z"`} {
wantRefused(t, bansJSON, `{"version": 1, "bans": [`+
`{"netblock": "203.0.113.9/32", "start": "2026-10-06T00:00:00Z", `+
`"expires": "2026-10-06T00:00:01Z"}, `+
`{"netblock": "203.0.113.10/32", "start": "2026-10-06T00:00:00Z", `+
`"expires": `+expires+`}]}`,
`: entry 2's expires is not after its start`)
}
}
func TestUnknownVersionStopsTheStart(t *testing.T) {
t.Parallel()
+6 -49
View File
@@ -5,15 +5,12 @@
# served through smallwebwaf, that a second one in a minute bans the
# client, that a probe for /.env bans another client, which its next
# request bans for good, that `sv stop` stops smallwebwaf in order, that
# `docker stop` stops the container without having to kill it, that a
# new container on the same volume still refuses the banned client, that
# a container with its state files on a new volume mounted at the
# directory SWWAF_STATE_DIR_FILE names becomes healthy, and that one whose
# SWWAF_STATE_DIR does not exist logs smallwebwaf's message about it. The
# `docker stop` stops the container without having to kill it, and that
# a new container on the same volume still refuses the banned client. The
# containers run with SWWAF_LOOKUP_SOURCE=off, so that no address is sent
# to GeoJS. The containers, the volumes and both images are removed
# however the script ends. Building the app needs network access, for
# nixpkgs' binary cache. script/check does not run this.
# to GeoJS. The containers, the volume and both images are removed however
# the script ends. Building the app needs network access, for nixpkgs'
# binary cache. script/check does not run this.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -26,15 +23,10 @@ IMAGE="$NAME-base"
APP_IMAGE="$NAME-app"
CONTAINER="$NAME"
VOLUME="$NAME-state"
# The volumes of the container whose state directory SWWAF_STATE_DIR_FILE
# names: its state files, and the file that names their directory.
MOVED_VOLUME="$NAME-moved-state"
SETTINGS_VOLUME="$NAME-settings"
cleanup() {
docker rm --force "$CONTAINER" >/dev/null 2>&1 || true
docker volume rm --force "$VOLUME" "$MOVED_VOLUME" "$SETTINGS_VOLUME" \
>/dev/null 2>&1 || true
docker volume rm --force "$VOLUME" >/dev/null 2>&1 || true
docker rmi --force "$APP_IMAGE" "$IMAGE" >/dev/null 2>&1 || true
}
@@ -84,27 +76,6 @@ start_container() {
address="$(docker port "$CONTAINER" 8080/tcp)"
}
# start_moved_container: run the app's container with the state files on
# a new volume at /srv/state, which SWWAF_STATE_DIR_FILE names in a file
# only the smallwebwaf user can read, as under "Settings given as files"
# in README.md, and wait until it is healthy. smallwebwaf can write the
# new volume only once the run script has given it to that user.
start_moved_container() {
docker volume create "$MOVED_VOLUME" >/dev/null
docker volume create "$SETTINGS_VOLUME" >/dev/null
docker run --rm --volume "$SETTINGS_VOLUME:/settings" \
--entrypoint bash "$IMAGE" -c 'echo /srv/state > /settings/state-dir &&
chown smallwebwaf:smallwebwaf /settings/state-dir &&
chmod 0400 /settings/state-dir'
docker run --detach --name "$CONTAINER" \
--volume "$MOVED_VOLUME:/srv/state" \
--volume "$SETTINGS_VOLUME:/etc/smallwebwaf/settings:ro" \
--env SWWAF_STATE_DIR_FILE=/etc/smallwebwaf/settings/state-dir \
--env SWWAF_LOOKUP_SOURCE=off \
"$APP_IMAGE" >/dev/null
wait_for "the health check did not pass" healthy
}
# refused: a request to the container gets 403, SWWAF_BAN_RESPONSE's
# default.
refused() {
@@ -169,20 +140,6 @@ main() {
wait_for "smallwebwaf logged no request refused under the ban" \
logged '"action":"banned"'
echo "example-app: a new container on the same volume keeps the ban"
docker rm --force "$CONTAINER" >/dev/null
start_moved_container
echo "example-app: the run script gives the state files' directory named" \
"by SWWAF_STATE_DIR_FILE to the smallwebwaf user"
docker rm --force "$CONTAINER" >/dev/null
docker run --detach --name "$CONTAINER" \
--env SWWAF_STATE_DIR=/srv/missing \
--env SWWAF_LOOKUP_SOURCE=off \
"$APP_IMAGE" >/dev/null
wait_for "smallwebwaf did not report the missing state directory" \
logged "SWWAF_STATE_DIR cannot be written"
echo "example-app: smallwebwaf reports a state directory that does not exist"
}
main "$@"
+3 -23
View File
@@ -5,32 +5,12 @@ set -euo pipefail
# exits; the wait spaces out the restarts. The state directory and every
# file in it are given to the smallwebwaf user, so that a volume mounted
# there needs no change of owner; chown -R changes a symbolic link itself,
# never what it points to. When SWWAF_STATE_DIR, in either form, cannot be
# read or is not the absolute path of a directory, no owner is changed,
# and smallwebwaf starts and stops with a message naming the setting. A
# change of owner that fails, as on a volume mounted read-only, does not
# end the script either: smallwebwaf reports a directory it cannot write.
# exec, so that the signal `sv stop` sends reaches smallwebwaf itself.
# never what it points to. exec, so that the signal `sv stop` sends
# reaches smallwebwaf itself.
main() {
sleep 1
local dir
if dir="$(state_dir)" && [[ "$dir" == /* && -d "$dir" ]]; then
chown -R smallwebwaf:smallwebwaf "$dir" || true
fi
chown -R smallwebwaf:smallwebwaf "${SWWAF_STATE_DIR:-/var/lib/smallwebwaf}"
exec chpst -u smallwebwaf:smallwebwaf /usr/local/bin/smallwebwaf
}
# state_dir prints SWWAF_STATE_DIR, /var/lib/smallwebwaf when it is unset,
# or what is in the file SWWAF_STATE_DIR_FILE names, less every newline at
# its end. It fails when both are set or the file cannot be read.
state_dir() {
if [[ ! -v SWWAF_STATE_DIR_FILE ]]; then
printf '%s\n' "${SWWAF_STATE_DIR-/var/lib/smallwebwaf}"
elif [[ ! -v SWWAF_STATE_DIR ]]; then
cat -- "$SWWAF_STATE_DIR_FILE" 2>/dev/null
else
return 1
fi
}
main "$@"