Compare commits
13
Commits
18469e236f
...
v1.0.0-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eafcc3fd73 | ||
|
|
da0c182a47 | ||
|
|
4cc492004a | ||
|
|
69178da59a | ||
|
|
d1d21a0469 | ||
|
|
7acc6ae153 | ||
|
|
75f28ee0b5 | ||
|
|
b0d54a2c51 | ||
|
|
d9f7344ae6 | ||
|
|
60c49a6740 | ||
|
|
47aac0235d | ||
|
|
6327f7f9c5 | ||
|
|
157cdc23f3 |
+6
-1
@@ -140,10 +140,14 @@ RUN mkdir /etc/sysusers.d \
|
|||||||
# apt also fetches the live archive's InRelease files, which change daily
|
# apt also fetches the live archive's InRelease files, which change daily
|
||||||
# and which the install does not use. The snapshot service is HTTPS only
|
# and which the install does not use. The snapshot service is HTTPS only
|
||||||
# and this image has no CA certificates yet, so this step uses the Go
|
# and this image has no CA certificates yet, so this step uses the Go
|
||||||
# image's.
|
# image's. The snapshot service answers 503 at times; apt retries a
|
||||||
|
# failed download, waiting twice as long before each retry, and this
|
||||||
|
# step gives it five retries, about half a minute in all, where its
|
||||||
|
# default of three gives up after seven seconds.
|
||||||
RUN --mount=type=bind,from=builder,source=/etc/ssl/certs/ca-certificates.crt,target=/tmp/go-image-ca.crt \
|
RUN --mount=type=bind,from=builder,source=/etc/ssl/certs/ca-certificates.crt,target=/tmp/go-image-ca.crt \
|
||||||
apt-get update --snapshot 20261001T000000Z \
|
apt-get update --snapshot 20261001T000000Z \
|
||||||
-o Acquire::https::CaInfo=/tmp/go-image-ca.crt \
|
-o Acquire::https::CaInfo=/tmp/go-image-ca.crt \
|
||||||
|
-o Acquire::Retries=5 \
|
||||||
&& printf '%s\n' \
|
&& printf '%s\n' \
|
||||||
'45f95ce276cdba3e41870516a130e03c58b8b7a79e9546b0efe9e526d255740c snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute_InRelease' \
|
'45f95ce276cdba3e41870516a130e03c58b8b7a79e9546b0efe9e526d255740c snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute_InRelease' \
|
||||||
'802e675dd9de4c7f3916434a95e7c1d8eec0e82886622d7805ab19a2c6fe0365 snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute-updates_InRelease' \
|
'802e675dd9de4c7f3916434a95e7c1d8eec0e82886622d7805ab19a2c6fe0365 snapshot.ubuntu.com_ubuntu_20261001T000000Z_dists_resolute-updates_InRelease' \
|
||||||
@@ -153,6 +157,7 @@ RUN --mount=type=bind,from=builder,source=/etc/ssl/certs/ca-certificates.crt,tar
|
|||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
||||||
--snapshot 20261001T000000Z \
|
--snapshot 20261001T000000Z \
|
||||||
-o Acquire::https::CaInfo=/tmp/go-image-ca.crt \
|
-o Acquire::https::CaInfo=/tmp/go-image-ca.crt \
|
||||||
|
-o Acquire::Retries=5 \
|
||||||
ca-certificates nix-bin runit \
|
ca-certificates nix-bin runit \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
|
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
|
||||||
run example-app
|
run example-app gitea-requests
|
||||||
|
|
||||||
# Makefile targets are thin shims; the implementations live in script/
|
# Makefile targets are thin shims; the implementations live in script/
|
||||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||||
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
|
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
|
||||||
# which test checks. build and run are for working on the code by hand;
|
# which test checks. build and run are for working on the code by hand;
|
||||||
# example-app checks the image with an app built on it.
|
# example-app checks the image with an app built on it; gitea-requests
|
||||||
|
# reports the gitea requests the Core Rule Set refuses at the defaults.
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@script/bootstrap
|
@script/bootstrap
|
||||||
@@ -45,3 +46,6 @@ run:
|
|||||||
|
|
||||||
example-app:
|
example-app:
|
||||||
@script/example-app
|
@script/example-app
|
||||||
|
|
||||||
|
gitea-requests:
|
||||||
|
@script/gitea-requests
|
||||||
|
|||||||
@@ -96,11 +96,14 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
|
|||||||
## What it does so far
|
## What it does so far
|
||||||
|
|
||||||
- Passes each request to the app and the app's answer back unchanged: method,
|
- Passes each request to the app and the app's answer back unchanged: method,
|
||||||
path, query, headers, body and status. Bodies stream through in both
|
path, query, headers, body and status. The one exception is a path starting
|
||||||
directions and are never held whole in memory, but for the part of a request
|
with `//` that holds a character Go escapes, such as `|`: Go cannot send it as
|
||||||
body the Core Rule Set reads, at most `SWWAF_WAF_BODY_LIMIT` and one byte
|
it is, and the app receives it decoded and escaped again, `%2F` as `/`. Bodies
|
||||||
more, which is held until the app is sent it. A WebSocket, or any other
|
stream through in both directions and are never held whole in memory, but for
|
||||||
upgraded connection, passes through, and the timeouts do not cut it.
|
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
|
- 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.
|
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
|
For a peer inside it, `X-Forwarded-For` is read from the right, and the first
|
||||||
@@ -110,16 +113,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
|
`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
|
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).
|
log line (see `request_id` in "Request log" below).
|
||||||
- Enforces the timeouts and the size limits below. A limit passed before the
|
- Enforces the timeouts and the size limits below. Once a request's line and
|
||||||
response has started gets `smallwebwaf`'s own answer: `408` for a client too
|
headers have been read, a limit passed before the response has started gets
|
||||||
slow to send its request, `413` for a request body that is too large, `504`
|
`smallwebwaf`'s own answer: `408` for a client too slow to send its body,
|
||||||
for an app too slow to answer, and `502` for a response that is too large or
|
`413` for a request body that is too large, `504` for an app too slow to
|
||||||
an app that cannot be reached. A request that announces a body over the limit
|
answer, and `502` for a response that is too large or an app that cannot be
|
||||||
is refused before anything reaches the app. While a request body is still on
|
reached. A request that announces a body over the limit is refused before
|
||||||
its way, a request timeout that runs out answers `408` if `smallwebwaf` was
|
anything reaches the app. While a request body is still on its way, a request
|
||||||
waiting for the client to send more, and `504` if it was waiting for the app
|
timeout that runs out answers `408` if `smallwebwaf` was waiting for the
|
||||||
to take what it had. Once the response has started, a limit can only cut the
|
client to send more, and `504` if it was waiting for the app to take what it
|
||||||
connection.
|
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
|
- 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
|
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
|
`SWWAF_BAN_RESPONSE`, `403` by default, before anything reaches the app, and
|
||||||
@@ -1042,9 +1045,11 @@ less severe than `SWWAF_LOG_LEVEL` are not written.
|
|||||||
Go's HTTP server, on which `smallwebwaf` is built, reads a request's line and
|
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,
|
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`,
|
without a line in the log: headers over `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES`,
|
||||||
which it answers `431`, headers slower than `SWWAF_CLIENT_REQUEST_TIMEOUT`,
|
which it answers `431`, and requests it cannot read at all, which it answers
|
||||||
whose connection it closes without an answer, and requests it cannot read at
|
itself, mostly with `400`. A client too slow to send its request line and
|
||||||
all, which it answers itself, mostly with `400`.
|
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.
|
||||||
|
|
||||||
### Sending the log to a syslog server
|
### Sending the log to a syslog server
|
||||||
|
|
||||||
@@ -1444,11 +1449,12 @@ 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`,
|
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`,
|
`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
|
`client`, `listed`, which is `false` for a client the zone does not list, or
|
||||||
`fetched`. So does a ban whose `cause` is not `limit`, `attack`, `admin` or
|
`fetched`. So does a ban whose `expires` is not after its `start` or whose
|
||||||
`crowdsec`, alerts waiting for a destination that is not `webhook`, `slack` or
|
`cause` is not `limit`, `attack`, `admin` or `crowdsec`, alerts waiting for a
|
||||||
`ntfy`, an anomaly counter whose `scope` is not `client`, `net`, `asn`, `total`
|
destination that is not `webhook`, `slack` or `ntfy`, an anomaly counter whose
|
||||||
or `watch`, and a copy of a list with a line that would make its fetch fail. An
|
`scope` is not `client`, `net`, `asn`, `total` or `watch`, and a copy of a list
|
||||||
answer's `asn` or `as_name` left out reads as empty.
|
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
|
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
|
a state file as soon as you save it: what the file then holds replaces what
|
||||||
@@ -1458,14 +1464,15 @@ 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
|
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
|
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
|
not parse, has another `version`, leaves out a field an entry needs, gives a ban
|
||||||
another `cause`, names another destination, gives an anomaly counter another
|
an `expires` not after its `start` or another `cause`, names another
|
||||||
`scope` or gives a list's copy a line that would make its fetch fail, does not
|
destination, gives an anomaly counter another `scope` or gives a list's copy a
|
||||||
stop the running `smallwebwaf`: it keeps what it holds, and at the file's next
|
line that would make its fetch fail, does not stop the running `smallwebwaf`: it
|
||||||
write renames your file to `<name>.bad`, such as `bans.json.bad`, writes the
|
keeps what it holds, and at the file's next write renames your file to
|
||||||
file again from memory, logs the file and where the error is, and raises a
|
`<name>.bad`, such as `bans.json.bad`, writes the file again from memory, logs
|
||||||
`file_error` alert for it. It waits for that write because an editor's file can
|
the file and where the error is, and raises a `file_error` alert for it. It
|
||||||
be read before the editor has finished writing it. Mend the `.bad` file and move
|
waits for that write because an editor's file can be read before the editor has
|
||||||
it back. A file you remove is written again at its next write.
|
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`
|
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
|
and its `expires`, `null` for a ban that never ends; its `reason` and its
|
||||||
@@ -1954,8 +1961,14 @@ main "$@"
|
|||||||
without one, it still starts. At each start the `run` script of `smallwebwaf`
|
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
|
gives that directory and every file in it to the `smallwebwaf` user, so a host
|
||||||
directory mounted there needs no change of owner.
|
directory mounted there needs no change of owner.
|
||||||
- `docker stop` has runit stop both processes. `smallwebwaf` then stops taking
|
- `docker stop` has runit stop both processes at once, each with SIGTERM.
|
||||||
requests and gives those in progress five seconds to finish.
|
`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.
|
||||||
|
|
||||||
A rule file is one rule per line: a name, what to match against, what to do, and
|
A rule file is one rule per line: a name, what to match against, what to do, and
|
||||||
a regex.
|
a regex.
|
||||||
@@ -2147,13 +2160,14 @@ addresses sends, so that one client costs at most one check every
|
|||||||
`SWWAF_REPUTATION_CACHE_TTL`.
|
`SWWAF_REPUTATION_CACHE_TTL`.
|
||||||
|
|
||||||
Only a client whose history counts an offence is checked, so that the checks are
|
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 or a byte limit,
|
spent on suspects: so far, one that has broken a rate limit, a byte limit or the
|
||||||
matched a ban rule, or had a request refused by a block rule or the Core Rule
|
error burst, matched a ban rule, asked for a trap path, or had a request refused
|
||||||
Set. A client dropped from the table of clients loses its history, and with it
|
by a block rule, by the Core Rule Set or for a missing or wrong token. A client
|
||||||
its offences. A client is checked in the background, at its first request after
|
dropped from the table of clients loses its history, and with it its offences. A
|
||||||
its offence that reaches the check: no request waits, a request refused under
|
client is checked in the background, at its first request after its offence that
|
||||||
its ban is not checked, and the request that has it checked, and any other from
|
reaches the check: no request waits, a request refused under its ban is not
|
||||||
it before the answer comes, goes on as from a client without a score.
|
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
|
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
|
`SWWAF_REPUTATION_ACTION` says what is done with its client, as for a DNSBL
|
||||||
@@ -2370,10 +2384,34 @@ so that they run in minimal containers.
|
|||||||
that the health check passes, that a request reaches the app through
|
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
|
`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,
|
for `/.env` bans another client, whose next request makes the ban permanent,
|
||||||
that `sv stop` and `docker stop` stop it in order, and that a new container on
|
that `sv stop` and `docker stop` stop it in order, that a new container on the
|
||||||
the same volume still refuses the banned client; then removes the containers,
|
same volume still refuses the banned client, that a container with its state
|
||||||
the volume and both images. It needs network access, for nixpkgs' binary
|
files on a new volume mounted at the directory `SWWAF_STATE_DIR_FILE` names
|
||||||
cache, and `script/check` does not run it; `make example-app` does.
|
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.
|
||||||
|
- `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
|
||||||
|
specific to gitea in [`SPEC.md`](SPEC.md) say are refused, it sends through
|
||||||
|
it: for each file the note on a file name in a query parameter names, the
|
||||||
|
API's list of its commits; for `Runtime.java` alone, the commit graph of that
|
||||||
|
file, the hidden lines of its diff and the API's call for the contents of
|
||||||
|
files that names it; the sign-in page gitea sends a visitor to on the way to
|
||||||
|
the edit page of `document.write.js`; the pages of `java.lang.String.md`,
|
||||||
|
`META-INF/services/org.apache.commons.logging.LogFactory`, `process.env.d.ts`
|
||||||
|
and `.../notes.txt`; and one container image push that reuses a layer. It does
|
||||||
|
not send the raw file, history, blame or edit page of those four files, the
|
||||||
|
pages of the directories `META-INF/services` and `...`, or the push again. It
|
||||||
|
also sends a short sample of gitea's ordinary use: signing in, pages of files
|
||||||
|
and commits, a diff, the API, a push, clone and fetch over HTTP, and two
|
||||||
|
container image pushes. It prints a line for each request `smallwebwaf`
|
||||||
|
logged, with what it did with it and the ids of the Core Rule Set's rules that
|
||||||
|
matched it, then removes the containers, the network and the images it made,
|
||||||
|
but gitea's own image. It needs network access, and a docker daemon that
|
||||||
|
pushes to a registry on `localhost` over plain HTTP, as docker does by
|
||||||
|
default; `script/check` does not run it, and `make gitea-requests` does.
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
|
|||||||
@@ -225,9 +225,9 @@ address or one /64, so a permanent ban does not reach neighbours who did
|
|||||||
nothing.
|
nothing.
|
||||||
|
|
||||||
An offence is a request `smallwebwaf` holds against the client: one that carries
|
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 or the Core
|
a clear sign of attack, breaks a limit, or is refused by a rule file, by the
|
||||||
Rule Set. Offences are counted by kind in the client's history. Two kinds lead
|
Core Rule Set or for a missing or wrong token. Offences are counted by kind in
|
||||||
to a ban, each by its own rule:
|
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
|
- 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
|
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
|
to send its request line and headers, and then, from the end of the
|
||||||
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, Go's HTTP server
|
||||||
`431` and closes the connection, and nothing reaches the app. It must be
|
answers `431` and closes the connection, and nothing reaches the app. It
|
||||||
more than `4K`, and cannot be `off`: Go's HTTP server always has such a
|
must be more than `4K`, and cannot be `off`: Go's HTTP server always has
|
||||||
limit, and reads 4 KiB past the one it is given before it refuses.
|
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
|
||||||
@@ -435,12 +435,13 @@ The settings, by group:
|
|||||||
the client sends it and the app receives it.
|
the client sends it and the app receives it.
|
||||||
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body, as
|
- `SWWAF_RESPONSE_MAX_BYTES` (default `5G`): the largest response body, as
|
||||||
the app sends it and the client receives it.
|
the app sends it and the client receives it.
|
||||||
- When a limit is passed before the response has started, `smallwebwaf`
|
- When a limit is passed after a request's line and headers have been read,
|
||||||
answers itself: `413` for a request body that is too large, `408` for a
|
but before the response has started, `smallwebwaf` answers itself: `413`
|
||||||
client that is too slow, `502` for a response that is too large, `504` for
|
for a request body that is too large, `408` for a client too slow to send
|
||||||
an app that is too slow. A request that announces a body larger than its
|
its body, `502` for a response that is too large, `504` for an app that is
|
||||||
limit is refused before anything reaches the app. Once the response has
|
too slow. A request that announces a body larger than its limit is refused
|
||||||
started it can only be cut off, and the connection is closed.
|
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
|
- 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
|
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
|
slows the client's send. So while a request body is still on its way, a
|
||||||
@@ -450,10 +451,11 @@ The settings, by group:
|
|||||||
waiting for the app to take what it had.
|
waiting for the app to take what it had.
|
||||||
- Go's HTTP server, on which `smallwebwaf` is built, reads a request's line
|
- 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
|
and headers before `smallwebwaf` sees the request. A client that takes
|
||||||
longer than `SWWAF_CLIENT_REQUEST_TIMEOUT` to send them gets no answer:
|
longer than `SWWAF_CLIENT_REQUEST_TIMEOUT` to send them gets either no
|
||||||
the server closes its connection. Headers over
|
answer or an error answer from the server, mostly `400 Bad Request`;
|
||||||
`SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` are answered `431` by the server
|
either way the server closes its connection and nothing reaches the app.
|
||||||
itself. Neither request gets a line in the request log.
|
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
|
- A WebSocket connection leaves these limits behind once it is upgraded: it
|
||||||
stays open until either side closes it.
|
stays open until either side closes it.
|
||||||
- Lookup of AS number and country (R7). On by default through GeoJS, which needs
|
- Lookup of AS number and country (R7). On by default through GeoJS, which needs
|
||||||
@@ -1324,9 +1326,16 @@ The two processes:
|
|||||||
- A `smallwebwaf` that stops at start, for example on invalid configuration, is
|
- A `smallwebwaf` that stops at start, for example on invalid configuration, is
|
||||||
started again every second and stops again, logging its message each time,
|
started again every second and stops again, logging its message each time,
|
||||||
until the cause is fixed. The health check fails meanwhile.
|
until the cause is fixed. The health check fails meanwhile.
|
||||||
- `docker stop` sends SIGTERM to `runsvinit`, which has runit stop both
|
- `docker stop` sends SIGTERM to `runsvinit`, which has runit stop both services
|
||||||
services, each with SIGTERM, and exits once they have stopped. `smallwebwaf`
|
at once, each with SIGTERM, and exits once they have stopped. `smallwebwaf`
|
||||||
writes its state files as it stops (see "Persistent state").
|
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.
|
||||||
- The container's root filesystem stays writable: runit writes each service's
|
- The container's root filesystem stays writable: runit writes each service's
|
||||||
status into its directory under `/etc/service`.
|
status into its directory under `/etc/service`.
|
||||||
|
|
||||||
@@ -1477,8 +1486,47 @@ holds any token file.
|
|||||||
Saving such a rule stores it, but the branch settings page gitea then
|
Saving such a rule stores it, but the branch settings page gitea then
|
||||||
returns to gets the 403 answer of `smallwebwaf`, so the save looks as
|
returns to gets the 403 answer of `smallwebwaf`, so the save looks as
|
||||||
if it failed.
|
if it failed.
|
||||||
|
- A file name in a query parameter, those the fourth change leaves out
|
||||||
|
of 930120, 932160 and 932260 included, in which the Core Rule Set's
|
||||||
|
rules for Java, script, PowerShell and SQL find what they look for:
|
||||||
|
`runtime` in OpenJDK's
|
||||||
|
`src/java.base/share/classes/java/lang/Runtime.java` (944110), a Java
|
||||||
|
class, as in `java.lang.String.md` (944130), `document.write.js`
|
||||||
|
(941180), `process.env.d.ts` (934100), `__proto__` (934130), the
|
||||||
|
PowerShell command `Install-Script`, as in `install-script.sh`
|
||||||
|
(932120), or a database's name, as in PostgreSQL's
|
||||||
|
`information_schema.sql` (942140). For such a file, the API's list of
|
||||||
|
its commits (`path`) is refused, and so are the API's call for the
|
||||||
|
contents of files that names it in its query (`body`), the commit
|
||||||
|
graph of that file alone (`file`), and the hidden lines of its diff
|
||||||
|
(`path`), which stay hidden. A visitor whom gitea sends to sign in on
|
||||||
|
the way to a page of such a file that needs signing in, such as its
|
||||||
|
edit page, gets the 403 answer of `smallwebwaf` in place of the
|
||||||
|
sign-in page (`redirect_to`), and can still sign in by opening the
|
||||||
|
sign-in page itself.
|
||||||
- A path: a file name ending in `~`, or an `.xhtml` file whose path
|
- A path: a file name ending in `~`, or an `.xhtml` file whose path
|
||||||
holds a space.
|
holds a space.
|
||||||
|
- A path that holds what the Core Rule Set takes for a Java class
|
||||||
|
(944130), as `java.lang.String.md` and
|
||||||
|
`META-INF/services/org.apache.commons.logging.LogFactory` do, or for
|
||||||
|
Node.js code (934100), as `process.env.d.ts` does, or that has a
|
||||||
|
directory named `...` (930100, 930110), such as `.../notes.txt`. The
|
||||||
|
page of such a file, its raw file, its history, its blame and its edit
|
||||||
|
page are refused; the directory that holds it lists it, and a clone
|
||||||
|
carries it.
|
||||||
|
- A container image push that reuses a layer of an image docker has
|
||||||
|
pushed to that gitea before, when that image's owner, or its name
|
||||||
|
after the owner, starts with an entry on the list of commands
|
||||||
|
(932260), such as `owner/python3-app`, `owner/docker-base` or any
|
||||||
|
image of an owner named `ansible` or `docker-team`. docker asks gitea
|
||||||
|
to take the layer from that image, naming it in the query parameter
|
||||||
|
`from`, which 932260 checks, and gets the 403 answer of `smallwebwaf`.
|
||||||
|
A docker that keeps its images in containerd's image store, as a new
|
||||||
|
install of Docker Engine 29 does, then fails the push, and fails each
|
||||||
|
new try the same way. A docker that keeps them in its own older image
|
||||||
|
store uploads the layer instead, so the push succeeds after one
|
||||||
|
refusal for each layer it reuses, and each of those refusals counts
|
||||||
|
toward the error burst.
|
||||||
- Artifact uploads from `actions/upload-artifact@v3` send the header
|
- Artifact uploads from `actions/upload-artifact@v3` send the header
|
||||||
`Content-Range`, which the Core Rule Set refuses (920450). The action
|
`Content-Range`, which the Core Rule Set refuses (920450). The action
|
||||||
sends two files at a time, does not retry a 403 and sends no further file
|
sends two files at a time, does not retry a 403 and sends no further file
|
||||||
@@ -1566,10 +1614,19 @@ holds any token file.
|
|||||||
- Core Rule Set false positives against real apps (a search for code, and any
|
- Core Rule Set false positives against real apps (a search for code, and any
|
||||||
body once `SWWAF_WAF_BODY_LIMIT` is set): a match refuses only that request
|
body once `SWWAF_WAF_BODY_LIMIT` is set): a match refuses only that request
|
||||||
and bans no one by itself; the request log names the rule, and exclusions by
|
and bans no one by itself; the request log names the rule, and exclusions by
|
||||||
rule id and path fix it. Further gitea requests the Core Rule Set may refuse
|
rule id and path fix it. `script/gitea-requests` runs gitea behind the image
|
||||||
at the defaults, found by reading gitea's source rather than a running gitea,
|
at the defaults and sends through it a short sample of gitea's ordinary use
|
||||||
are collected in https://git.eeqj.de/sneak/smallwebwaf/issues/30 and checked
|
and some of the requests that three of the notes specific to gitea say are
|
||||||
when milestone 1 runs in front of a real gitea.
|
refused: for each file the note on a file name in a query parameter names, the
|
||||||
|
API's list of its commits; for `Runtime.java` alone, the commit graph of that
|
||||||
|
file, the hidden lines of its diff and the API's call for the contents of
|
||||||
|
files that names it; the sign-in page gitea sends a visitor to on the way to
|
||||||
|
the edit page of `document.write.js`; the pages of `java.lang.String.md`,
|
||||||
|
`META-INF/services/org.apache.commons.logging.LogFactory`, `process.env.d.ts`
|
||||||
|
and `.../notes.txt`; and one container image push that reuses a layer, which
|
||||||
|
it does not try again. It reports each request the Core Rule Set refuses, so
|
||||||
|
that those requests can be checked again when gitea or the Core Rule Set
|
||||||
|
changes version.
|
||||||
- Attacks carried in request bodies: not refused by default, since on a code
|
- Attacks carried in request bodies: not refused by default, since on a code
|
||||||
forge bodies are full of code the Core Rule Set takes for attacks. Most of
|
forge bodies are full of code the Core Rule Set takes for attacks. Most of
|
||||||
what shows in URLs and headers is still refused, the client that sends an
|
what shows in URLs and headers is still refused, the client that sends an
|
||||||
|
|||||||
@@ -61,6 +61,59 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
|
|||||||
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 0, third: 1})
|
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 0, third: 1})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rules := defaultRules()
|
||||||
|
rules.MaxBans = 2
|
||||||
|
ledger := bans.New(rules)
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.1/32")
|
||||||
|
other := netip.MustParsePrefix("203.0.113.2/32")
|
||||||
|
|
||||||
|
// An admin bans the netblock for a minute, and it then breaks a limit
|
||||||
|
// twice, each time after its last ban has ended.
|
||||||
|
ledger.BanForAdmin(netblock, midnight(), midnight().Add(time.Minute),
|
||||||
|
"probes for logins")
|
||||||
|
first, _ := ledger.BanForLimit(netblock, midnight().Add(time.Minute), bans.Notes{})
|
||||||
|
second, _ := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
||||||
|
|
||||||
|
// A ban on another netblock fills the ledger, and the netblock's first
|
||||||
|
// ban for a limit, its earliest that is not an admin's, goes.
|
||||||
|
ledger.BanForLimit(other, second.Start, bans.Notes{})
|
||||||
|
wantBans(t, ledger, map[netip.Prefix]int{netblock: 2, other: 1})
|
||||||
|
|
||||||
|
// The netblock's next ban still counts it.
|
||||||
|
third, _ := ledger.BanForLimit(netblock, second.Expires, bans.Notes{})
|
||||||
|
if third.Notes.EarlierBans != (bans.EarlierBans{Limit: 2, Admin: 1}) {
|
||||||
|
t.Errorf("the third ban for a limit has earlier bans %+v, "+
|
||||||
|
"want 2 for a limit and 1 an admin's", third.Notes.EarlierBans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ledger := bans.New(defaultRules())
|
||||||
|
netblock := netip.MustParsePrefix("203.0.113.1/32")
|
||||||
|
|
||||||
|
// The netblock breaks a limit twice, the second time after its first
|
||||||
|
// ban has ended, and the second ban's notes count the first. An admin
|
||||||
|
// then keeps the first ban.
|
||||||
|
first, _ := ledger.BanForLimit(netblock, midnight(), bans.Notes{})
|
||||||
|
second, _ := ledger.BanForLimit(netblock, first.Expires, bans.Notes{})
|
||||||
|
|
||||||
|
kept := first
|
||||||
|
kept.Cause = bans.CauseAdmin
|
||||||
|
ledger.LoadEdit([]bans.Ban{kept, second})
|
||||||
|
|
||||||
|
// The netblock's next ban counts the kept ban once, as an admin's.
|
||||||
|
third, _ := ledger.BanForLimit(netblock, second.Expires, bans.Notes{})
|
||||||
|
if third.Notes.EarlierBans != (bans.EarlierBans{Limit: 1, Admin: 1}) {
|
||||||
|
t.Errorf("the third ban for a limit has earlier bans %+v, "+
|
||||||
|
"want 1 for a limit and 1 an admin's", third.Notes.EarlierBans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReasonOfTheBansSmallwebwafMakes(t *testing.T) {
|
func TestReasonOfTheBansSmallwebwafMakes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+35
-10
@@ -687,26 +687,51 @@ func (l *Ledger) ban(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// earlierBans returns how many bans a netblock with the bans held, oldest
|
// earlierBans returns how many bans a netblock with the bans held, oldest
|
||||||
// first, has had, by cause: the first ban held counts the bans the
|
// first, has had, by cause. Each ban held gives a count: the bans its
|
||||||
// netblock had before that one, since dropped to make room, and each ban
|
// notes count before it, and one for it and each ban held after it. A
|
||||||
// held adds one.
|
// ban's count misses a ban dropped after it to make room, and that of an
|
||||||
|
// admin's ban added to bans.json without notes misses the bans before it,
|
||||||
|
// so the count that names the most bans is taken, whole: a ban an admin
|
||||||
|
// keeps by giving it CauseAdmin is still counted under its old cause in
|
||||||
|
// later bans' notes, and taking each cause from another count would count
|
||||||
|
// it twice.
|
||||||
func earlierBans(held []Ban) EarlierBans {
|
func earlierBans(held []Ban) EarlierBans {
|
||||||
earlier := held[0].Notes.EarlierBans
|
// fromHere counts ban and the bans held after it.
|
||||||
|
var most, fromHere EarlierBans
|
||||||
|
|
||||||
for _, ban := range held {
|
for _, ban := range slices.Backward(held) {
|
||||||
switch ban.Cause {
|
switch ban.Cause {
|
||||||
case CauseLimit:
|
case CauseLimit:
|
||||||
earlier.Limit++
|
fromHere.Limit++
|
||||||
case CauseAttack:
|
case CauseAttack:
|
||||||
earlier.Attack++
|
fromHere.Attack++
|
||||||
case CauseAdmin:
|
case CauseAdmin:
|
||||||
earlier.Admin++
|
fromHere.Admin++
|
||||||
case CauseCrowdSec:
|
case CauseCrowdSec:
|
||||||
earlier.CrowdSec++
|
fromHere.CrowdSec++
|
||||||
|
}
|
||||||
|
|
||||||
|
notes := ban.Notes.EarlierBans
|
||||||
|
count := EarlierBans{
|
||||||
|
Limit: notes.Limit + fromHere.Limit,
|
||||||
|
Attack: notes.Attack + fromHere.Attack,
|
||||||
|
Admin: notes.Admin + fromHere.Admin,
|
||||||
|
CrowdSec: notes.CrowdSec + fromHere.CrowdSec,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Of counts that name as many bans, the earlier ban's is taken: it
|
||||||
|
// counts more of the bans held by the cause they have now.
|
||||||
|
if count.total() >= most.total() {
|
||||||
|
most = count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return earlier
|
return most
|
||||||
|
}
|
||||||
|
|
||||||
|
// total returns how many bans e counts, of all causes.
|
||||||
|
func (e EarlierBans) total() int {
|
||||||
|
return e.Limit + e.Attack + e.Admin + e.CrowdSec
|
||||||
}
|
}
|
||||||
|
|
||||||
// markChanged has Changed receive a value, unless one is waiting already.
|
// markChanged has Changed receive a value, unless one is waiting already.
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
@@ -211,9 +213,20 @@ func (w *responseWriter) FlushError() error {
|
|||||||
return err
|
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
|
// Unwrap lets http.ResponseController reach net/http's own
|
||||||
// ResponseWriter, which is how ReverseProxy takes over the connection of
|
// ResponseWriter for what responseWriter does not do itself.
|
||||||
// an upgraded request.
|
|
||||||
func (w *responseWriter) Unwrap() http.ResponseWriter {
|
func (w *responseWriter) Unwrap() http.ResponseWriter {
|
||||||
return w.ResponseWriter
|
return w.ResponseWriter
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
// limit while the Core Rule Set reads it, which it notes nothing of.
|
// limit while the Core Rule Set reads it, which it notes nothing of.
|
||||||
func (rq *request) checkCoreRuleSet() string {
|
func (rq *request) checkCoreRuleSet() string {
|
||||||
cfg := rq.h.config
|
cfg := rq.h.config
|
||||||
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in.URL, cfg.WAFExemptPaths) {
|
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,47 @@ func TestPassesRequestAndAnswerUnchanged(t *testing.T) {
|
|||||||
wantRequestFields(t, line, addr, len(requestBody), len(answerBody))
|
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.
|
// wantAppSaw checks that the app received the test's request unchanged.
|
||||||
func wantAppSaw(t *testing.T, saw appSaw, body []byte) {
|
func wantAppSaw(t *testing.T, saw appSaw, body []byte) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -151,6 +192,36 @@ 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) {
|
func TestStreamsTheRequestBody(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -309,6 +380,23 @@ 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) {
|
func TestServerHasTheDefaultLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,9 @@ func TestRateLimitCountsPathsThatAreNotExempt(t *testing.T) {
|
|||||||
"/assets/x%2Fy",
|
"/assets/x%2Fy",
|
||||||
"/assets/x%2fy",
|
"/assets/x%2fy",
|
||||||
`/assets/x\y`,
|
`/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.Run(sent, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
+56
-10
@@ -8,7 +8,6 @@ import (
|
|||||||
"net/http/httptrace"
|
"net/http/httptrace"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -22,6 +21,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// flushAfterEachWrite has ReverseProxy pass on each part of the app's
|
// 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,
|
Method: r.Method,
|
||||||
Scheme: scheme(r, peerTrusted),
|
Scheme: scheme(r, peerTrusted),
|
||||||
Host: r.Host,
|
Host: r.Host,
|
||||||
Path: r.URL.EscapedPath(),
|
Path: rules.Path(r),
|
||||||
Query: r.URL.RawQuery,
|
Query: r.URL.RawQuery,
|
||||||
Protocol: r.Proto,
|
Protocol: r.Proto,
|
||||||
Referer: r.Referer(),
|
Referer: r.Referer(),
|
||||||
@@ -284,7 +284,7 @@ func (rq *request) checkClient(ctx context.Context) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rq.counted = !isInside(rq.client, cfg.RateLimitExemptNets) &&
|
rq.counted = !isInside(rq.client, cfg.RateLimitExemptNets) &&
|
||||||
!pathExempt(rq.in.URL, cfg.RateLimitExemptPaths)
|
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
|
||||||
if rq.counted {
|
if rq.counted {
|
||||||
rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
|
rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
|
||||||
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
|
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
|
||||||
@@ -307,7 +307,7 @@ func (rq *request) checkClient(ctx context.Context) string {
|
|||||||
return rq.checkCoreRuleSet()
|
return rq.checkCoreRuleSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
// pathExempt reports whether a request for u is exempt under prefixes,
|
// pathExempt reports whether r is exempt under prefixes,
|
||||||
// SWWAF_RATE_LIMIT_EXEMPT_PATHS or SWWAF_WAF_EXEMPT_PATHS: whether its
|
// SWWAF_RATE_LIMIT_EXEMPT_PATHS or SWWAF_WAF_EXEMPT_PATHS: whether its
|
||||||
// path as sent, the path the app receives, not percent-decoded, starts
|
// 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
|
// with one of prefixes, so that /%61ssets/x is not under /assets/ for an
|
||||||
@@ -316,10 +316,9 @@ func (rq *request) checkClient(ctx context.Context) string {
|
|||||||
// encoded slash (%2F or %2f), never is, since an app may act on it as a
|
// 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
|
// path outside every prefix: /assets/..%2Flogin as /login, or /assets%2Fx
|
||||||
// as one path segment, as Go's router does.
|
// as one path segment, as Go's router does.
|
||||||
func pathExempt(u *url.URL, prefixes []string) bool {
|
func pathExempt(r *http.Request, prefixes []string) bool {
|
||||||
decoded := u.Path
|
decoded := r.URL.Path
|
||||||
// EscapedPath is the path as the app receives it, not decoded.
|
sent := rules.Path(r)
|
||||||
sent := u.EscapedPath()
|
|
||||||
|
|
||||||
if strings.Contains(decoded, "..") || strings.Contains(decoded, `\`) ||
|
if strings.Contains(decoded, "..") || strings.Contains(decoded, `\`) ||
|
||||||
strings.Contains(strings.ToLower(sent), "%2f") {
|
strings.Contains(strings.ToLower(sent), "%2f") {
|
||||||
@@ -375,6 +374,17 @@ func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
|||||||
// ReverseProxy drops query parameters it cannot parse; the app gets
|
// ReverseProxy drops query parameters it cannot parse; the app gets
|
||||||
// the query as the client sent it.
|
// the query as the client sent it.
|
||||||
pr.Out.URL.RawQuery = pr.In.URL.RawQuery
|
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)
|
setForwardedHeaders(pr.In, pr.Out, rq.peer, rq.peerTrusted)
|
||||||
pr.Out.Header.Set(requestIDHeader, rq.line.RequestID)
|
pr.Out.Header.Set(requestIDHeader, rq.line.RequestID)
|
||||||
pr.Out.Header.Del(asnHeader)
|
pr.Out.Header.Del(asnHeader)
|
||||||
@@ -395,8 +405,10 @@ func (rq *request) modifyResponse(res *http.Response) error {
|
|||||||
// timeouts. ReverseProxy writes this answer straight to the
|
// timeouts. ReverseProxy writes this answer straight to the
|
||||||
// connection it takes over, not through rq.out, and then copies
|
// connection it takes over, not through rq.out, and then copies
|
||||||
// what passes each way through res.Body, the connection to the app.
|
// 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.stopTimers()
|
||||||
rq.out.status = res.StatusCode
|
|
||||||
rq.line.Websocket = true
|
rq.line.Websocket = true
|
||||||
|
|
||||||
conn, ok := res.Body.(io.ReadWriteCloser)
|
conn, ok := res.Body.(io.ReadWriteCloser)
|
||||||
@@ -419,6 +431,13 @@ func (rq *request) modifyResponse(res *http.Response) error {
|
|||||||
return errResponseTooLarge
|
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}
|
res.Body = &responseBody{body: limitBody(res.Body, maxBytes), rq: rq}
|
||||||
rq.startClientResponseTimeout()
|
rq.startClientResponseTimeout()
|
||||||
|
|
||||||
@@ -428,6 +447,13 @@ func (rq *request) modifyResponse(res *http.Response) error {
|
|||||||
// answerError is ReverseProxy's ErrorHandler: the request could not be
|
// answerError is ReverseProxy's ErrorHandler: the request could not be
|
||||||
// passed to the app, or the app's answer cannot be passed on.
|
// passed to the app, or the app's answer cannot be passed on.
|
||||||
func (rq *request) answerError(_ http.ResponseWriter, _ *http.Request, err error) {
|
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()
|
refused := rq.refused.Load()
|
||||||
if refused == nil {
|
if refused == nil {
|
||||||
if rq.in.Context().Err() != nil {
|
if rq.in.Context().Err() != nil {
|
||||||
@@ -710,6 +736,16 @@ func (rq *request) requestTimedOut(limit string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rq.body == nil || !rq.body.waiting.Load() {
|
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{
|
rq.refuse(refusal{
|
||||||
status: http.StatusGatewayTimeout,
|
status: http.StatusGatewayTimeout,
|
||||||
action: requestlog.ActionTimedOut,
|
action: requestlog.ActionTimedOut,
|
||||||
@@ -795,9 +831,19 @@ func (rq *request) responseTimedOut() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// responseReceived is called once the app has sent its whole answer.
|
// responseReceived is called once the app has sent its whole answer. An
|
||||||
|
// app may answer before the client has sent its whole body, as with a 404
|
||||||
|
// it sends without reading it. The timeouts then run on: until the
|
||||||
|
// transport's read of the body returns, Go's server cannot send the
|
||||||
|
// answer, and SWWAF_CLIENT_REQUEST_TIMEOUT is what ends that read for a
|
||||||
|
// client that stops sending.
|
||||||
func (rq *request) responseReceived() {
|
func (rq *request) responseReceived() {
|
||||||
rq.complete = true
|
rq.complete = true
|
||||||
|
|
||||||
|
if rq.body != nil && !rq.body.received.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
rq.stopTimers()
|
rq.stopTimers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -194,6 +195,151 @@ func sendLargeBody(t *testing.T, addr string) net.Conn {
|
|||||||
return conn
|
return conn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClientStopsSendingAfterTheAppHasAnswered(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// answeredAt is when the app answered, or nil if the bytes the client
|
||||||
|
// sent never reached it.
|
||||||
|
var answeredAt atomic.Pointer[time.Time]
|
||||||
|
|
||||||
|
// The app answers without reading the whole body, as an app does for
|
||||||
|
// a path it does not serve, and only once smallwebwaf waits for the
|
||||||
|
// rest of it: the app 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, r *http.Request) {
|
||||||
|
_, err := io.ReadFull(r.Body, make([]byte, len("the first bytes")))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(shortTimeout / 2)
|
||||||
|
answeredAt.Store(new(time.Now()))
|
||||||
|
http.NotFound(w, r)
|
||||||
|
})
|
||||||
|
addr, out := startProxy(t, app.URL, map[string]string{
|
||||||
|
clientRequestTimeout: shortTimeoutSetting,
|
||||||
|
metricsToken: token,
|
||||||
|
})
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
got := readResponse(t, sendPartOfBody(t, addr))
|
||||||
|
wantTimedOut(t, start)
|
||||||
|
|
||||||
|
// Close returns once the app has finished with the request.
|
||||||
|
app.Close()
|
||||||
|
|
||||||
|
// The app's answer came before the time ran out, so the client is
|
||||||
|
// sent that answer, not smallwebwaf's 408. Only a hold-up of the test
|
||||||
|
// process can make it come later, and SPEC.md then asks for 408, as
|
||||||
|
// smallwebwaf was waiting for the client, or for 504 if the bytes the
|
||||||
|
// client sent had not reached the app.
|
||||||
|
at := answeredAt.Load()
|
||||||
|
want := http.StatusNotFound
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case at == nil:
|
||||||
|
want = http.StatusGatewayTimeout
|
||||||
|
case at.Sub(start) >= shortTimeout:
|
||||||
|
want = http.StatusRequestTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
wantStatus(t, got, want)
|
||||||
|
wantLine(t, out.requestLine(t), want, requestlog.ActionTimedOut)
|
||||||
|
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) {
|
func TestAppTooSlowToAnswer(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ var (
|
|||||||
// errMissing is for an entry without a field it needs.
|
// errMissing is for an entry without a field it needs.
|
||||||
errMissing = errors.New("has no")
|
errMissing = errors.New("has no")
|
||||||
errCause = errors.New("is not limit, attack, admin or crowdsec")
|
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")
|
errDestination = errors.New("is not webhook, slack or ntfy")
|
||||||
errScope = errors.New("is not client, net, asn, total or watch")
|
errScope = errors.New("is not client, net, asn, total or watch")
|
||||||
errWaitingList = errors.New(`waiting is a list, but now lists the alerts by ` +
|
errWaitingList = errors.New(`waiting is a list, but now lists the alerts by ` +
|
||||||
@@ -646,8 +647,10 @@ func (e BanEntry) ban() bans.Ban {
|
|||||||
// client, a start, from which the length of the netblock's next ban is
|
// 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
|
// 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
|
// ban's expires is null, which Bans cannot tell from a missing one, so
|
||||||
// each expires is read again as written. A cause other than limit,
|
// each expires is read again as written. An expires not after the start
|
||||||
// attack, admin or crowdsec, most likely misspelt, is refused too.
|
// 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.
|
||||||
func (f *bansFile) check(data []byte) error {
|
func (f *bansFile) check(data []byte) error {
|
||||||
var written struct {
|
var written struct {
|
||||||
Bans []struct {
|
Bans []struct {
|
||||||
@@ -668,6 +671,8 @@ func (f *bansFile) check(data []byte) error {
|
|||||||
return missing(i, "start")
|
return missing(i, "start")
|
||||||
case written.Bans[i].Expires == nil:
|
case written.Bans[i].Expires == nil:
|
||||||
return missing(i, "expires")
|
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 &&
|
case entry.Cause != "" && entry.Cause != bans.CauseLimit &&
|
||||||
entry.Cause != bans.CauseAttack && entry.Cause != bans.CauseAdmin &&
|
entry.Cause != bans.CauseAttack && entry.Cause != bans.CauseAdmin &&
|
||||||
entry.Cause != bans.CauseCrowdSec:
|
entry.Cause != bans.CauseCrowdSec:
|
||||||
|
|||||||
@@ -865,6 +865,21 @@ func TestBanWithAnotherCauseStopsTheStart(t *testing.T) {
|
|||||||
`: entry 4's cause "atack" is not limit, attack, admin or crowdsec`)
|
`: 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) {
|
func TestUnknownVersionStopsTheStart(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+49
-6
@@ -5,12 +5,15 @@
|
|||||||
# served through smallwebwaf, that a second one in a minute bans the
|
# served through smallwebwaf, that a second one in a minute bans the
|
||||||
# client, that a probe for /.env bans another client, which its next
|
# client, that a probe for /.env bans another client, which its next
|
||||||
# request bans for good, that `sv stop` stops smallwebwaf in order, that
|
# request bans for good, that `sv stop` stops smallwebwaf in order, that
|
||||||
# `docker stop` stops the container without having to kill it, and that
|
# `docker stop` stops the container without having to kill it, that a
|
||||||
# a new container on the same volume still refuses the banned client. The
|
# 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
|
||||||
# containers run with SWWAF_LOOKUP_SOURCE=off, so that no address is sent
|
# containers run with SWWAF_LOOKUP_SOURCE=off, so that no address is sent
|
||||||
# to GeoJS. The containers, the volume and both images are removed however
|
# to GeoJS. The containers, the volumes and both images are removed
|
||||||
# the script ends. Building the app needs network access, for nixpkgs'
|
# however the script ends. Building the app needs network access, for
|
||||||
# binary cache. script/check does not run this.
|
# nixpkgs' binary cache. script/check does not run this.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
@@ -23,10 +26,15 @@ IMAGE="$NAME-base"
|
|||||||
APP_IMAGE="$NAME-app"
|
APP_IMAGE="$NAME-app"
|
||||||
CONTAINER="$NAME"
|
CONTAINER="$NAME"
|
||||||
VOLUME="$NAME-state"
|
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() {
|
cleanup() {
|
||||||
docker rm --force "$CONTAINER" >/dev/null 2>&1 || true
|
docker rm --force "$CONTAINER" >/dev/null 2>&1 || true
|
||||||
docker volume rm --force "$VOLUME" >/dev/null 2>&1 || true
|
docker volume rm --force "$VOLUME" "$MOVED_VOLUME" "$SETTINGS_VOLUME" \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
docker rmi --force "$APP_IMAGE" "$IMAGE" >/dev/null 2>&1 || true
|
docker rmi --force "$APP_IMAGE" "$IMAGE" >/dev/null 2>&1 || true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +84,27 @@ start_container() {
|
|||||||
address="$(docker port "$CONTAINER" 8080/tcp)"
|
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
|
# refused: a request to the container gets 403, SWWAF_BAN_RESPONSE's
|
||||||
# default.
|
# default.
|
||||||
refused() {
|
refused() {
|
||||||
@@ -140,6 +169,20 @@ main() {
|
|||||||
wait_for "smallwebwaf logged no request refused under the ban" \
|
wait_for "smallwebwaf logged no request refused under the ban" \
|
||||||
logged '"action":"banned"'
|
logged '"action":"banned"'
|
||||||
echo "example-app: a new container on the same volume keeps the ban"
|
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 "$@"
|
main "$@"
|
||||||
|
|||||||
Executable
+335
@@ -0,0 +1,335 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/gitea-requests: build the image and run gitea 1.27.3 behind it,
|
||||||
|
# with every setting of smallwebwaf at its default but SWWAF_UPSTREAM_URL,
|
||||||
|
# which names gitea's container. Then send through it the gitea requests
|
||||||
|
# that reading gitea's source found the Core Rule Set refuses, and a short
|
||||||
|
# sample of gitea's ordinary use: signing in, pages of files and commits,
|
||||||
|
# a diff, the API, a push, clone and fetch over HTTP, and two container
|
||||||
|
# image pushes. Last, print each request smallwebwaf logged, with what it
|
||||||
|
# did with it and the ids of the Core Rule Set's rules that matched it.
|
||||||
|
# Every request comes from one client, docker's gateway, so more than
|
||||||
|
# SWWAF_ERROR_BURST_THRESHOLD, 30, refusals in a minute would ban it, and
|
||||||
|
# the requests after would show as banned. The containers, the network,
|
||||||
|
# the images and the files the script makes are removed however it ends;
|
||||||
|
# gitea's own image stays, as the base images of a build do. It needs
|
||||||
|
# network access, and a docker daemon that pushes to a registry on
|
||||||
|
# localhost over plain HTTP, as docker does by default. script/check does
|
||||||
|
# not run this.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
|
# gitea 1.27.3, 2026-08-29
|
||||||
|
GITEA_IMAGE="gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef"
|
||||||
|
|
||||||
|
# Named after this run, so that runs in other clones on the same host
|
||||||
|
# never touch each other's.
|
||||||
|
NAME="$("$SCRIPT_DIR/projectname")-gitea-$$"
|
||||||
|
IMAGE="$NAME-image"
|
||||||
|
NETWORK="$NAME"
|
||||||
|
SMALLWEBWAF="$NAME-smallwebwaf"
|
||||||
|
GITEA="$NAME-gitea"
|
||||||
|
|
||||||
|
# gitea's one user, who owns the repository and the container images.
|
||||||
|
PASSWORD="owner-password"
|
||||||
|
|
||||||
|
BROWSER="Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0"
|
||||||
|
|
||||||
|
# The files the script makes, and smallwebwaf's address once it runs,
|
||||||
|
# localhost and the port docker publishes it on: a host name, since the
|
||||||
|
# Core Rule Set takes a Host that is an IP address for a warning (920350).
|
||||||
|
WORK=""
|
||||||
|
address=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
# So that a signal cannot stop it partway through.
|
||||||
|
trap '' HUP INT TERM
|
||||||
|
docker rm --force --volumes "$SMALLWEBWAF" "$GITEA" >/dev/null 2>&1 || true
|
||||||
|
docker network rm "$NETWORK" >/dev/null 2>&1 || true
|
||||||
|
docker rmi --force "$IMAGE" >/dev/null 2>&1 || true
|
||||||
|
if [ -n "$address" ]; then
|
||||||
|
docker rmi --force "$address/owner/python3-app:1" "$address/owner/app:1" \
|
||||||
|
>/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
rm -rf "$WORK"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "gitea-requests: $*" >&2
|
||||||
|
report
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# wait_for <what fails> <command>...: run the command every second until
|
||||||
|
# it succeeds, for at most a minute.
|
||||||
|
wait_for() {
|
||||||
|
failure="$1"
|
||||||
|
shift
|
||||||
|
tries=0
|
||||||
|
until "$@"; do
|
||||||
|
tries=$((tries + 1))
|
||||||
|
[ "$tries" -lt 60 ] || fail "$failure"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
healthy() {
|
||||||
|
status="$(docker inspect --format '{{.State.Health.Status}}' "$SMALLWEBWAF")"
|
||||||
|
[ "$status" = healthy ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# logged <text>...: a line of smallwebwaf's output holds every text, in
|
||||||
|
# any order.
|
||||||
|
logged() {
|
||||||
|
lines="$(docker logs "$SMALLWEBWAF" 2>&1)"
|
||||||
|
for text in "$@"; do
|
||||||
|
lines="$(printf '%s\n' "$lines" | grep -F "$text")" || return 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# send <label> <path> <curl option>...: send a request for path to
|
||||||
|
# smallwebwaf, with label as its X-Request-ID, which the report gives,
|
||||||
|
# and keep the body of the answer in $WORK/answer. The Core Rule Set
|
||||||
|
# reads that header too, so a label names no file the cases name.
|
||||||
|
send() {
|
||||||
|
label="$1"
|
||||||
|
path="$2"
|
||||||
|
shift 2
|
||||||
|
curl --silent --show-error --max-time 30 --output "$WORK/answer" \
|
||||||
|
--header "X-Request-ID: $label" "$@" "http://$address$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
# page <label> <path> <curl option>...: send the request as a browser
|
||||||
|
# does, with the cookies gitea has set. Its Referer is the repository's
|
||||||
|
# page, which links to the others, and never a page the Core Rule Set
|
||||||
|
# refused: its address would have the next request refused too.
|
||||||
|
page() {
|
||||||
|
label="$1"
|
||||||
|
path="$2"
|
||||||
|
shift 2
|
||||||
|
send "$label" "$path" --compressed --user-agent "$BROWSER" \
|
||||||
|
--header "Accept: text/html,application/xhtml+xml,*/*;q=0.8" \
|
||||||
|
--header "Accept-Language: en-US,en;q=0.5" \
|
||||||
|
--cookie "$WORK/cookies" --cookie-jar "$WORK/cookies" \
|
||||||
|
--referer "http://$address/owner/repo" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# api <label> <path> <curl option>...: send the request as a client of
|
||||||
|
# gitea's API does, as the owner.
|
||||||
|
api() {
|
||||||
|
label="$1"
|
||||||
|
path="$2"
|
||||||
|
shift 2
|
||||||
|
send "$label" "$path" --user "owner:$PASSWORD" \
|
||||||
|
--header "Accept: application/json" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# repo_git <git argument>...: run git as the owner, without the host's
|
||||||
|
# own git settings, so that nothing it does is kept outside $WORK.
|
||||||
|
repo_git() {
|
||||||
|
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 git \
|
||||||
|
-c user.name=owner -c user.email=owner@example.invalid "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# lines: forty lines of text, enough for a diff to hide some of them.
|
||||||
|
lines() {
|
||||||
|
i=1
|
||||||
|
while [ "$i" -le 40 ]; do
|
||||||
|
echo "line $i"
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# make_repository: create the repository owner/repo, holding a file of
|
||||||
|
# each name the cases name, and push it, which is the push over HTTP of
|
||||||
|
# the sample. The second commit adds a line at the end of Runtime.java,
|
||||||
|
# so that its diff hides the lines above.
|
||||||
|
make_repository() {
|
||||||
|
status="$(api create-repository /api/v1/user/repos \
|
||||||
|
--header "Content-Type: application/json" --data '{"name":"repo"}' \
|
||||||
|
--write-out '%{http_code}')"
|
||||||
|
[ "$status" = 201 ] || fail "creating the repository was answered $status"
|
||||||
|
|
||||||
|
dir="$WORK/repo"
|
||||||
|
repo_git init --quiet --initial-branch=main "$dir"
|
||||||
|
for file in \
|
||||||
|
src/java.base/share/classes/java/lang/Runtime.java \
|
||||||
|
java.lang.String.md \
|
||||||
|
META-INF/services/org.apache.commons.logging.LogFactory \
|
||||||
|
document.write.js \
|
||||||
|
process.env.d.ts \
|
||||||
|
test/fixtures/__proto__.json \
|
||||||
|
install-script.sh \
|
||||||
|
src/backend/catalog/information_schema.sql \
|
||||||
|
.../notes.txt \
|
||||||
|
README.md; do
|
||||||
|
mkdir -p "$dir/$(dirname "$file")"
|
||||||
|
lines >"$dir/$file"
|
||||||
|
repo_git -C "$dir" add "$file"
|
||||||
|
done
|
||||||
|
repo_git -C "$dir" commit --quiet --message "Add the files"
|
||||||
|
echo "line 41" >>"$dir/src/java.base/share/classes/java/lang/Runtime.java"
|
||||||
|
repo_git -C "$dir" commit --quiet --all --message "Add a line"
|
||||||
|
|
||||||
|
repo_git -C "$dir" -c "http.extraHeader=X-Request-ID: git-push" push --quiet \
|
||||||
|
"http://owner:$PASSWORD@$address/owner/repo.git" main ||
|
||||||
|
fail "the push of the repository failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# browse: sign in, then open pages as a person does. The cases are those
|
||||||
|
# of https://git.eeqj.de/sneak/smallwebwaf/issues/30.
|
||||||
|
browse() {
|
||||||
|
# Case: gitea sends a visitor who is not signed in to the sign-in
|
||||||
|
# page, with the page asked for in redirect_to, when that page needs
|
||||||
|
# signing in, as a file's edit page does.
|
||||||
|
page issue-30-case /owner/repo/_edit/main/document.write.js --location
|
||||||
|
|
||||||
|
page sign-in /user/login
|
||||||
|
status="$(page sign-in /user/login --header "Origin: http://$address" \
|
||||||
|
--data-urlencode user_name=owner --data-urlencode "password=$PASSWORD" \
|
||||||
|
--write-out '%{http_code}')"
|
||||||
|
[ "$status" = 303 ] || fail "signing in was answered $status"
|
||||||
|
|
||||||
|
page browse /
|
||||||
|
page browse /owner/repo
|
||||||
|
page browse /owner/repo/src/branch/main/README.md
|
||||||
|
page browse /owner/repo/commits/branch/main
|
||||||
|
page browse "/owner/repo/commit/$(repo_git -C "$WORK/repo" rev-parse HEAD)"
|
||||||
|
|
||||||
|
# Case: expanding the lines the diff hides, by the link the diff's
|
||||||
|
# page gives for it.
|
||||||
|
excerpt="$(sed -n 's/.*data-fetch-url="\([^"]*blob_excerpt[^"]*\)".*/\1/p' \
|
||||||
|
"$WORK/answer" | head -n 1 | sed 's/&/\&/g')"
|
||||||
|
[ -n "$excerpt" ] || fail "the diff's page has no link to its hidden lines"
|
||||||
|
page issue-30-case "$excerpt"
|
||||||
|
|
||||||
|
# Cases: the commit graph of one file, and the pages of files whose
|
||||||
|
# path the Core Rule Set takes for an attack.
|
||||||
|
page issue-30-case "/owner/repo/graph?file=src/java.base/share/classes/java/lang/Runtime.java"
|
||||||
|
page issue-30-case /owner/repo/src/branch/main/META-INF/services/org.apache.commons.logging.LogFactory
|
||||||
|
page issue-30-case /owner/repo/src/branch/main/java.lang.String.md
|
||||||
|
page issue-30-case /owner/repo/src/branch/main/process.env.d.ts
|
||||||
|
page issue-30-case /owner/repo/src/branch/main/.../notes.txt
|
||||||
|
}
|
||||||
|
|
||||||
|
# call_api: the API's calls the cases name: the commits of a file, for a
|
||||||
|
# file of each name the Core Rule Set takes for an attack in the query
|
||||||
|
# parameter path, and the file-contents call, which takes its list of
|
||||||
|
# files as JSON in the query parameter body.
|
||||||
|
call_api() {
|
||||||
|
for file in \
|
||||||
|
src/java.base/share/classes/java/lang/Runtime.java \
|
||||||
|
java.lang.String.md \
|
||||||
|
document.write.js \
|
||||||
|
process.env.d.ts \
|
||||||
|
test/fixtures/__proto__.json \
|
||||||
|
install-script.sh \
|
||||||
|
src/backend/catalog/information_schema.sql; do
|
||||||
|
api issue-30-case /api/v1/repos/owner/repo/commits --get --data-urlencode "path=$file"
|
||||||
|
done
|
||||||
|
api issue-30-case /api/v1/repos/owner/repo/file-contents --get \
|
||||||
|
--data-urlencode 'body={"files":["src/java.base/share/classes/java/lang/Runtime.java"]}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# push_images: push a container image to owner/python3-app, then the same
|
||||||
|
# image to owner/app. For the second, the case, docker asks gitea to take
|
||||||
|
# its layer from owner/python3-app, naming it in the query parameter
|
||||||
|
# from. docker sends no X-Request-ID, so its requests have the ids
|
||||||
|
# smallwebwaf gives them. The image is one small file, imported rather
|
||||||
|
# than built.
|
||||||
|
push_images() {
|
||||||
|
echo "$NAME" >"$WORK/layer.txt"
|
||||||
|
tar -cf - -C "$WORK" layer.txt |
|
||||||
|
docker import - "$address/owner/python3-app:1" >/dev/null
|
||||||
|
docker tag "$address/owner/python3-app:1" "$address/owner/app:1"
|
||||||
|
|
||||||
|
# The login is written here rather than made with docker login, which
|
||||||
|
# would keep it in the host's credential helper when it has one.
|
||||||
|
mkdir "$WORK/docker"
|
||||||
|
printf '{"auths":{"%s":{"auth":"%s"}}}\n' "$address" \
|
||||||
|
"$(printf 'owner:%s' "$PASSWORD" | base64)" >"$WORK/docker/config.json"
|
||||||
|
for image in "$address/owner/python3-app:1" "$address/owner/app:1"; do
|
||||||
|
DOCKER_CONFIG="$WORK/docker" docker push --quiet "$image" >/dev/null ||
|
||||||
|
echo "gitea-requests: the push of $image failed" >&2
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# clone_and_fetch: the clone and fetch over HTTP of the sample.
|
||||||
|
clone_and_fetch() {
|
||||||
|
repo_git -c "http.extraHeader=X-Request-ID: git-clone" clone --quiet \
|
||||||
|
"http://owner:$PASSWORD@$address/owner/repo.git" "$WORK/clone" ||
|
||||||
|
fail "the clone failed"
|
||||||
|
repo_git -C "$WORK/clone" -c "http.extraHeader=X-Request-ID: git-fetch" fetch --quiet ||
|
||||||
|
fail "the fetch failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# field <name> <line>: the text of the field name in line, a line of the
|
||||||
|
# request log, or nothing when it has none.
|
||||||
|
field() {
|
||||||
|
printf '%s\n' "$2" | sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p"
|
||||||
|
}
|
||||||
|
|
||||||
|
# report: print a line for each request smallwebwaf logged, but its
|
||||||
|
# health checks: what it did with the request, the ids of the Core Rule
|
||||||
|
# Set's rules that matched it, its X-Request-ID, and the request.
|
||||||
|
# waf_blocked is a refusal by the Core Rule Set, and forward a request
|
||||||
|
# passed on to gitea, whatever gitea answered.
|
||||||
|
report() {
|
||||||
|
docker logs "$SMALLWEBWAF" 2>/dev/null | grep '"type":"request"' |
|
||||||
|
grep -v '"action":"admin"' | sed 's/\\u0026/\&/g' >"$WORK/requests"
|
||||||
|
while IFS= read -r line; do
|
||||||
|
ids="$(printf '%s\n' "$line" | sed -n 's/.*"waf_rule_ids":\[\([0-9,]*\)\].*/\1/p')"
|
||||||
|
query="$(field query "$line")"
|
||||||
|
printf '%-11s %-13s %-17s %s %s%s\n' "$(field action "$line")" "${ids:--}" \
|
||||||
|
"$(field request_id "$line")" "$(field method "$line")" \
|
||||||
|
"$(field path "$line")" "${query:+?$query}"
|
||||||
|
done <"$WORK/requests"
|
||||||
|
echo "gitea-requests: $(grep -c . "$WORK/requests") requests," \
|
||||||
|
"$(grep -c '"action":"waf_blocked"' "$WORK/requests") refused by the Core Rule Set"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 1' HUP INT TERM
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
|
||||||
|
docker build --no-cache -t "$IMAGE" .
|
||||||
|
|
||||||
|
# smallwebwaf first, so that gitea can be given the address it is
|
||||||
|
# reached at, and gitea's image pulled before, so that gitea starts
|
||||||
|
# within the minute smallwebwaf's health check waits for it.
|
||||||
|
docker pull --quiet "$GITEA_IMAGE" >/dev/null
|
||||||
|
docker network create "$NETWORK" >/dev/null
|
||||||
|
docker run --detach --name "$SMALLWEBWAF" --network "$NETWORK" \
|
||||||
|
--publish 127.0.0.1::8080 \
|
||||||
|
--env SWWAF_UPSTREAM_URL="http://$GITEA:3000" \
|
||||||
|
"$IMAGE" >/dev/null
|
||||||
|
published="$(docker port "$SMALLWEBWAF" 8080/tcp)"
|
||||||
|
address="localhost:${published##*:}"
|
||||||
|
|
||||||
|
docker run --detach --name "$GITEA" --network "$NETWORK" \
|
||||||
|
--env GITEA__security__INSTALL_LOCK=true \
|
||||||
|
--env GITEA__server__ROOT_URL="http://$address/" \
|
||||||
|
"$GITEA_IMAGE" >/dev/null
|
||||||
|
# smallwebwaf's health check passes once gitea takes connections.
|
||||||
|
wait_for "the health check did not pass" healthy
|
||||||
|
docker exec --user git "$GITEA" gitea admin user create --admin \
|
||||||
|
--username owner --password "$PASSWORD" --email owner@example.invalid \
|
||||||
|
--must-change-password=false >/dev/null
|
||||||
|
|
||||||
|
make_repository
|
||||||
|
browse
|
||||||
|
call_api
|
||||||
|
push_images
|
||||||
|
clone_and_fetch
|
||||||
|
|
||||||
|
# The fetch's last request is the last of all; once it is logged, so
|
||||||
|
# is every other.
|
||||||
|
wait_for "smallwebwaf logged no line for the fetch" \
|
||||||
|
logged '"request_id":"git-fetch"' '"method":"POST"'
|
||||||
|
report
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
+23
-3
@@ -5,12 +5,32 @@ set -euo pipefail
|
|||||||
# exits; the wait spaces out the restarts. The state directory and every
|
# 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
|
# 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,
|
# there needs no change of owner; chown -R changes a symbolic link itself,
|
||||||
# never what it points to. exec, so that the signal `sv stop` sends
|
# never what it points to. When SWWAF_STATE_DIR, in either form, cannot be
|
||||||
# reaches smallwebwaf itself.
|
# 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.
|
||||||
main() {
|
main() {
|
||||||
sleep 1
|
sleep 1
|
||||||
chown -R smallwebwaf:smallwebwaf "${SWWAF_STATE_DIR:-/var/lib/smallwebwaf}"
|
local dir
|
||||||
|
if dir="$(state_dir)" && [[ "$dir" == /* && -d "$dir" ]]; then
|
||||||
|
chown -R smallwebwaf:smallwebwaf "$dir" || true
|
||||||
|
fi
|
||||||
exec chpst -u smallwebwaf:smallwebwaf /usr/local/bin/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 "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user