Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2f60b924d |
@@ -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
|
||||
@@ -113,16 +110,16 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
|
||||
`X-Forwarded-Proto`, and `X-Forwarded-For` with the peer added at the end. It
|
||||
also gets the request's id in `X-Request-ID`, the same id as in the request's
|
||||
log line (see `request_id` in "Request log" below).
|
||||
- Enforces the timeouts and the size limits below. Once a request's line and
|
||||
headers have been read, a limit passed before the response has started gets
|
||||
`smallwebwaf`'s own answer: `408` for a client too slow to send its body,
|
||||
`413` for a request body that is too large, `504` for an app too slow to
|
||||
answer, and `502` for a response that is too large or an app that cannot be
|
||||
reached. A request that announces a body over the limit is refused before
|
||||
anything reaches the app. While a request body is still on its way, a request
|
||||
timeout that runs out answers `408` if `smallwebwaf` was waiting for the
|
||||
client to send more, and `504` if it was waiting for the app to take what it
|
||||
had. Once the response has started, a limit can only cut the connection.
|
||||
- Enforces the timeouts and the size limits below. A limit passed before the
|
||||
response has started gets `smallwebwaf`'s own answer: `408` for a client too
|
||||
slow to send its request, `413` for a request body that is too large, `504`
|
||||
for an app too slow to answer, and `502` for a response that is too large or
|
||||
an app that cannot be reached. A request that announces a body over the limit
|
||||
is refused before anything reaches the app. While a request body is still on
|
||||
its way, a request timeout that runs out answers `408` if `smallwebwaf` was
|
||||
waiting for the client to send more, and `504` if it was waiting for the app
|
||||
to take what it had. Once the response has started, a limit can only cut the
|
||||
connection.
|
||||
- Counts each client's requests over a minute, an hour and a day. A request that
|
||||
takes the client over one of the rate limits below is refused with
|
||||
`SWWAF_BAN_RESPONSE`, `403` by default, before anything reaches the app, and
|
||||
@@ -1045,11 +1042,9 @@ less severe than `SWWAF_LOG_LEVEL` are not written.
|
||||
Go's HTTP server, on which `smallwebwaf` is built, reads a request's line and
|
||||
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.
|
||||
which it answers `431`, headers slower than `SWWAF_CLIENT_REQUEST_TIMEOUT`,
|
||||
whose connection it closes without an answer, and requests it cannot read at
|
||||
all, which it answers itself, mostly with `400`.
|
||||
|
||||
### Sending the log to a syslog server
|
||||
|
||||
@@ -1449,12 +1444,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 +1458,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 +1954,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 +2147,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 +2370,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
|
||||
|
||||
@@ -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
|
||||
@@ -414,10 +414,10 @@ The settings, by group:
|
||||
to send its request line and headers, and then, from the end of the
|
||||
headers, its body.
|
||||
- `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` (default `32K`): the largest
|
||||
request line and headers a client may send. Over it, Go's HTTP server
|
||||
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.
|
||||
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.
|
||||
- `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
|
||||
@@ -435,13 +435,12 @@ The settings, by group:
|
||||
the client sends it and the app receives it.
|
||||
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body, as
|
||||
the app sends it and the client receives it.
|
||||
- When a limit is passed after a request's line and headers have been read,
|
||||
but before the response has started, `smallwebwaf` answers itself: `413`
|
||||
for a request body that is too large, `408` for a client too slow to send
|
||||
its body, `502` for a response that is too large, `504` for an app that is
|
||||
too slow. A request that announces a body larger than its limit is refused
|
||||
before anything reaches the app. Once the response has started it can only
|
||||
be cut off, and the connection is closed.
|
||||
- When a limit is passed before the response has started, `smallwebwaf`
|
||||
answers itself: `413` for a request body that is too large, `408` for a
|
||||
client that is too slow, `502` for a response that is too large, `504` for
|
||||
an app that is too slow. A request that announces a body larger than its
|
||||
limit is refused before anything reaches the app. Once the response has
|
||||
started it can only be cut off, and the connection is closed.
|
||||
- Since a request body streams through, each side can hold up the other: a
|
||||
slow client slows the send to the app, and an app slow to take the body
|
||||
slows the client's send. So while a request body is still on its way, a
|
||||
@@ -451,11 +450,10 @@ The settings, by group:
|
||||
waiting for the app to take what it had.
|
||||
- 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.
|
||||
longer than `SWWAF_CLIENT_REQUEST_TIMEOUT` to send them gets no answer:
|
||||
the server closes its connection. 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 +1324,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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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 "$@"
|
||||
|
||||
Reference in New Issue
Block a user