Compare commits
1
Commits
next
..
c35c204e93
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c35c204e93 |
@@ -1,12 +1,11 @@
|
|||||||
.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 gitea-requests
|
run example-app
|
||||||
|
|
||||||
# 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; gitea-requests
|
# example-app checks the image with an app built on it.
|
||||||
# reports the gitea requests the Core Rule Set refuses at the defaults.
|
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@script/bootstrap
|
@script/bootstrap
|
||||||
@@ -46,6 +45,3 @@ run:
|
|||||||
|
|
||||||
example-app:
|
example-app:
|
||||||
@script/example-app
|
@script/example-app
|
||||||
|
|
||||||
gitea-requests:
|
|
||||||
@script/gitea-requests
|
|
||||||
|
|||||||
@@ -96,14 +96,11 @@ 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. The one exception is a path starting
|
path, query, headers, body and status. Bodies stream through in both
|
||||||
with `//` that holds a character Go escapes, such as `|`: Go cannot send it as
|
directions and are never held whole in memory, but for the part of a request
|
||||||
it is, and the app receives it decoded and escaped again, `%2F` as `/`. Bodies
|
body the Core Rule Set reads, at most `SWWAF_WAF_BODY_LIMIT` and one byte
|
||||||
stream through in both directions and are never held whole in memory, but for
|
more, which is held until the app is sent it. A WebSocket, or any other
|
||||||
the part of a request body the Core Rule Set reads, at most
|
upgraded connection, passes through, and the timeouts do not cut it.
|
||||||
`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
|
||||||
@@ -113,16 +110,16 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
|
|||||||
`X-Forwarded-Proto`, and `X-Forwarded-For` with the peer added at the end. It
|
`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. Once a request's line and
|
- Enforces the timeouts and the size limits below. A limit passed before the
|
||||||
headers have been read, a limit passed before the response has started gets
|
response has started gets `smallwebwaf`'s own answer: `408` for a client too
|
||||||
`smallwebwaf`'s own answer: `408` for a client too slow to send its body,
|
slow to send its request, `413` for a request body that is too large, `504`
|
||||||
`413` for a request body that is too large, `504` for an app too slow to
|
for an app too slow to answer, and `502` for a response that is too large or
|
||||||
answer, and `502` for a response that is too large or an app that cannot be
|
an app that cannot be reached. A request that announces a body over the limit
|
||||||
reached. A request that announces a body over the limit is refused before
|
is refused before anything reaches the app. While a request body is still on
|
||||||
anything reaches the app. While a request body is still on its way, a request
|
its way, a request timeout that runs out answers `408` if `smallwebwaf` was
|
||||||
timeout that runs out answers `408` if `smallwebwaf` was waiting for the
|
waiting for the client to send more, and `504` if it was waiting for the app
|
||||||
client to send more, and `504` if it was waiting for the app to take what it
|
to take what it had. Once the response has started, a limit can only cut the
|
||||||
had. Once the response has started, a limit can only cut the connection.
|
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
|
||||||
@@ -1045,11 +1042,9 @@ less severe than `SWWAF_LOG_LEVEL` are not written.
|
|||||||
Go's HTTP server, on which `smallwebwaf` is built, reads a request's line and
|
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`, and requests it cannot read at all, which it answers
|
which it answers `431`, headers slower than `SWWAF_CLIENT_REQUEST_TIMEOUT`,
|
||||||
itself, mostly with `400`. A client too slow to send its request line and
|
whose connection it closes without an answer, and requests it cannot read at
|
||||||
headers within `SWWAF_CLIENT_REQUEST_TIMEOUT` gets either no answer or an error
|
all, which it answers itself, mostly with `400`.
|
||||||
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
|
||||||
|
|
||||||
@@ -1449,12 +1444,11 @@ whole service, its `asn`, for an AS number, its `name`, for a named netblock, or
|
|||||||
the `start` of a window in which it has requests or bytes; a list's `url`,
|
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 `expires` is not after its `start` or whose
|
`fetched`. So does a ban whose `cause` is not `limit`, `attack`, `admin` or
|
||||||
`cause` is not `limit`, `attack`, `admin` or `crowdsec`, alerts waiting for a
|
`crowdsec`, alerts waiting for a destination that is not `webhook`, `slack` or
|
||||||
destination that is not `webhook`, `slack` or `ntfy`, an anomaly counter whose
|
`ntfy`, an anomaly counter whose `scope` is not `client`, `net`, `asn`, `total`
|
||||||
`scope` is not `client`, `net`, `asn`, `total` or `watch`, and a copy of a list
|
or `watch`, and a copy of a list with a line that would make its fetch fail. An
|
||||||
with a line that would make its fetch fail. An answer's `asn` or `as_name` left
|
answer's `asn` or `as_name` left out reads as empty.
|
||||||
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
|
||||||
@@ -1464,15 +1458,14 @@ writes a file it takes in any edit made since, so your edit is not overwritten;
|
|||||||
a change `smallwebwaf` made after you opened the file, such as a new ban, is
|
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
|
||||||
an `expires` not after its `start` or another `cause`, names another
|
another `cause`, names another destination, gives an anomaly counter another
|
||||||
destination, gives an anomaly counter another `scope` or gives a list's copy a
|
`scope` or gives a list's copy a line that would make its fetch fail, does not
|
||||||
line that would make its fetch fail, does not stop the running `smallwebwaf`: it
|
stop the running `smallwebwaf`: it keeps what it holds, and at the file's next
|
||||||
keeps what it holds, and at the file's next write renames your file to
|
write renames your file to `<name>.bad`, such as `bans.json.bad`, writes the
|
||||||
`<name>.bad`, such as `bans.json.bad`, writes the file again from memory, logs
|
file again from memory, logs the file and where the error is, and raises a
|
||||||
the file and where the error is, and raises a `file_error` alert for it. It
|
`file_error` alert for it. It waits for that write because an editor's file can
|
||||||
waits for that write because an editor's file can be read before the editor has
|
be read before the editor has finished writing it. Mend the `.bad` file and move
|
||||||
finished writing it. Mend the `.bad` file and move it back. A file you remove is
|
it back. A file you remove is written again at its next write.
|
||||||
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
|
||||||
@@ -1961,14 +1954,8 @@ 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 at once, each with SIGTERM.
|
- `docker stop` has runit stop both processes. `smallwebwaf` then stops taking
|
||||||
`smallwebwaf` then stops taking requests and gives those in progress five
|
requests and gives those in progress five seconds to finish.
|
||||||
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.
|
||||||
@@ -2160,14 +2147,13 @@ 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, a byte limit or the
|
spent on suspects: so far, one that has broken a rate limit or a byte limit,
|
||||||
error burst, matched a ban rule, asked for a trap path, or had a request refused
|
matched a ban rule, or had a request refused by a block rule or the Core Rule
|
||||||
by a block rule, by the Core Rule Set or for a missing or wrong token. A client
|
Set. A client dropped from the table of clients loses its history, and with it
|
||||||
dropped from the table of clients loses its history, and with it its offences. A
|
its offences. A client is checked in the background, at its first request after
|
||||||
client is checked in the background, at its first request after its offence that
|
its offence that reaches the check: no request waits, a request refused under
|
||||||
reaches the check: no request waits, a request refused under its ban is not
|
its ban is not checked, and the request that has it checked, and any other from
|
||||||
checked, and the request that has it checked, and any other from it before the
|
it before the answer comes, goes on as from a client without a score.
|
||||||
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
|
||||||
@@ -2384,34 +2370,10 @@ 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, that a new container on the
|
that `sv stop` and `docker stop` stop it in order, and that a new container on
|
||||||
same volume still refuses the banned client, that a container with its state
|
the same volume still refuses the banned client; then removes the containers,
|
||||||
files on a new volume mounted at the directory `SWWAF_STATE_DIR_FILE` names
|
the volume and both images. It needs network access, for nixpkgs' binary
|
||||||
becomes healthy, and that one whose `SWWAF_STATE_DIR` does not exist logs
|
cache, and `script/check` does not run it; `make example-app` does.
|
||||||
`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, by the
|
a clear sign of attack, breaks a limit, or is refused by a rule file or the Core
|
||||||
Core Rule Set or for a missing or wrong token. Offences are counted by kind in
|
Rule Set. Offences are counted by kind in the client's history. Two kinds lead
|
||||||
the client's history. Two kinds lead to a ban, each by its own rule:
|
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, Go's HTTP server
|
request line and headers a client may send. Over it, `smallwebwaf` answers
|
||||||
answers `431` and closes the connection, and nothing reaches the app. It
|
`431` and closes the connection, and nothing reaches the app. It must be
|
||||||
must be more than `4K`, and cannot be `off`: Go's HTTP server always has
|
more than `4K`, and cannot be `off`: Go's HTTP server always has such a
|
||||||
such a limit, and reads 4 KiB past the one it is given before it refuses.
|
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,13 +435,12 @@ 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 after a request's line and headers have been read,
|
- When a limit is passed before the response has started, `smallwebwaf`
|
||||||
but before the response has started, `smallwebwaf` answers itself: `413`
|
answers itself: `413` for a request body that is too large, `408` for a
|
||||||
for a request body that is too large, `408` for a client too slow to send
|
client that is too slow, `502` for a response that is too large, `504` for
|
||||||
its body, `502` for a response that is too large, `504` for an app that is
|
an app that is too slow. A request that announces a body larger than its
|
||||||
too slow. A request that announces a body larger than its limit is refused
|
limit is refused before anything reaches the app. Once the response has
|
||||||
before anything reaches the app. Once the response has started it can only
|
started it can only be cut off, and the connection is closed.
|
||||||
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
|
||||||
@@ -451,11 +450,10 @@ 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 either no
|
longer than `SWWAF_CLIENT_REQUEST_TIMEOUT` to send them gets no answer:
|
||||||
answer or an error answer from the server, mostly `400 Bad Request`;
|
the server closes its connection. Headers over
|
||||||
either way the server closes its connection and nothing reaches the app.
|
`SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` are answered `431` by the server
|
||||||
Headers over `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` are answered `431` by
|
itself. Neither request gets a line in the request log.
|
||||||
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
|
||||||
@@ -1326,16 +1324,9 @@ 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 services
|
- `docker stop` sends SIGTERM to `runsvinit`, which has runit stop both
|
||||||
at once, each with SIGTERM, and exits once they have stopped. `smallwebwaf`
|
services, each with SIGTERM, and exits once they have stopped. `smallwebwaf`
|
||||||
stops taking requests, gives those in progress five seconds to finish, and
|
writes its state files as it stops (see "Persistent state").
|
||||||
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`.
|
||||||
|
|
||||||
@@ -1486,47 +1477,8 @@ 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
|
||||||
@@ -1614,19 +1566,10 @@ 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. `script/gitea-requests` runs gitea behind the image
|
rule id and path fix it. Further gitea requests the Core Rule Set may refuse
|
||||||
at the defaults and sends through it a short sample of gitea's ordinary use
|
at the defaults, found by reading gitea's source rather than a running gitea,
|
||||||
and some of the requests that three of the notes specific to gitea say are
|
are collected in https://git.eeqj.de/sneak/smallwebwaf/issues/30 and checked
|
||||||
refused: for each file the note on a file name in a query parameter names, the
|
when milestone 1 runs in front of a real gitea.
|
||||||
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
|
||||||
|
|||||||
@@ -90,30 +90,6 @@ func TestEarlierBansCountABanDroppedAfterAnAdminsBan(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
|
|||||||
+5
-21
@@ -691,10 +691,7 @@ func (l *Ledger) ban(
|
|||||||
// notes count before it, and one for it and each ban held after it. A
|
// notes count before it, and one for it and each ban held after it. A
|
||||||
// ban's count misses a ban dropped after it to make room, and that of an
|
// 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,
|
// 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
|
// so each cause gets the largest of these counts.
|
||||||
// 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 {
|
||||||
// fromHere counts ban and the bans held after it.
|
// fromHere counts ban and the bans held after it.
|
||||||
var most, fromHere EarlierBans
|
var most, fromHere EarlierBans
|
||||||
@@ -712,28 +709,15 @@ func earlierBans(held []Ban) EarlierBans {
|
|||||||
}
|
}
|
||||||
|
|
||||||
notes := ban.Notes.EarlierBans
|
notes := ban.Notes.EarlierBans
|
||||||
count := EarlierBans{
|
most.Limit = max(most.Limit, notes.Limit+fromHere.Limit)
|
||||||
Limit: notes.Limit + fromHere.Limit,
|
most.Attack = max(most.Attack, notes.Attack+fromHere.Attack)
|
||||||
Attack: notes.Attack + fromHere.Attack,
|
most.Admin = max(most.Admin, notes.Admin+fromHere.Admin)
|
||||||
Admin: notes.Admin + fromHere.Admin,
|
most.CrowdSec = max(most.CrowdSec, notes.CrowdSec+fromHere.CrowdSec)
|
||||||
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 most
|
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.
|
||||||
func (l *Ledger) markChanged() {
|
func (l *Ledger) markChanged() {
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
@@ -213,20 +211,9 @@ 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 for what responseWriter does not do itself.
|
// ResponseWriter, which is how ReverseProxy takes over the connection of
|
||||||
|
// an upgraded request.
|
||||||
func (w *responseWriter) Unwrap() http.ResponseWriter {
|
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, cfg.WAFExemptPaths) {
|
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in.URL, cfg.WAFExemptPaths) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,47 +74,6 @@ 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()
|
||||||
@@ -192,36 +151,6 @@ func wantRequestFields(t *testing.T, line logLine, host string, sent, received i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAddsNoContentTypeTheAppDidNotSend(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const typed = "text/plain"
|
|
||||||
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.URL.Path == "/typed" {
|
|
||||||
w.Header().Set("Content-Type", typed)
|
|
||||||
} else {
|
|
||||||
// The key with no value keeps Go's server from adding one.
|
|
||||||
w.Header()["Content-Type"] = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = io.WriteString(w, "<!doctype html><title>page</title>")
|
|
||||||
})
|
|
||||||
addr, _ := startProxy(t, app.URL, nil)
|
|
||||||
|
|
||||||
got := get(t, addr, "/")
|
|
||||||
if got.header.Values("Content-Type") != nil {
|
|
||||||
t.Errorf("client got Content-Type %q, want none",
|
|
||||||
got.header.Get("Content-Type"))
|
|
||||||
}
|
|
||||||
|
|
||||||
got = get(t, addr, "/typed")
|
|
||||||
if got.header.Get("Content-Type") != typed {
|
|
||||||
t.Errorf("client got Content-Type %q, want %q",
|
|
||||||
got.header.Get("Content-Type"), typed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStreamsTheRequestBody(t *testing.T) {
|
func TestStreamsTheRequestBody(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -380,23 +309,6 @@ func echoAfterUpgrade(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnswers502WhenTheAppSwitchesToAnotherProtocol(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// answerAfterUpgrade switches to websocket whatever the client asked
|
|
||||||
// for, and ReverseProxy passes on a switch only to the protocol the
|
|
||||||
// client asked for, here "websocket, foo".
|
|
||||||
app := startApp(t, answerAfterUpgrade)
|
|
||||||
addr, out := startProxy(t, app.URL, nil)
|
|
||||||
|
|
||||||
conn := dial(t, addr)
|
|
||||||
send(t, conn, "GET /ws HTTP/1.1\r\nHost: app\r\n"+
|
|
||||||
"Connection: Upgrade\r\nUpgrade: websocket, foo\r\n\r\n")
|
|
||||||
|
|
||||||
wantStatus(t, readResponse(t, conn), http.StatusBadGateway)
|
|
||||||
wantLine(t, out.requestLine(t), http.StatusBadGateway, requestlog.ActionUpstreamError)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServerHasTheDefaultLimits(t *testing.T) {
|
func TestServerHasTheDefaultLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -185,9 +185,6 @@ 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()
|
||||||
|
|||||||
+10
-56
@@ -8,6 +8,7 @@ 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"
|
||||||
@@ -21,7 +22,6 @@ 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: rules.Path(r),
|
Path: r.URL.EscapedPath(),
|
||||||
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, cfg.RateLimitExemptPaths)
|
!pathExempt(rq.in.URL, 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 r is exempt under prefixes,
|
// pathExempt reports whether a request for u is exempt under prefixes,
|
||||||
// SWWAF_RATE_LIMIT_EXEMPT_PATHS or SWWAF_WAF_EXEMPT_PATHS: whether its
|
// 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,9 +316,10 @@ func (rq *request) checkClient(ctx context.Context) string {
|
|||||||
// encoded slash (%2F or %2f), never is, since an app may act on it as a
|
// 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(r *http.Request, prefixes []string) bool {
|
func pathExempt(u *url.URL, prefixes []string) bool {
|
||||||
decoded := r.URL.Path
|
decoded := u.Path
|
||||||
sent := rules.Path(r)
|
// EscapedPath is the path as the app receives it, not decoded.
|
||||||
|
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") {
|
||||||
@@ -374,17 +375,6 @@ 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)
|
||||||
@@ -405,10 +395,8 @@ 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)
|
||||||
@@ -431,13 +419,6 @@ 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()
|
||||||
|
|
||||||
@@ -447,13 +428,6 @@ 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 {
|
||||||
@@ -736,16 +710,6 @@ 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,
|
||||||
@@ -831,19 +795,9 @@ func (rq *request) responseTimedOut() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// responseReceived is called once the app has sent its whole answer. An
|
// responseReceived is called once the app has sent its whole answer.
|
||||||
// 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
rq := &request{
|
|
||||||
h: &handler{config: &config.Config{}},
|
|
||||||
out: &responseWriter{ResponseWriter: w},
|
|
||||||
}
|
|
||||||
app := &http.Response{
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Header: http.Header{},
|
|
||||||
Body: http.NoBody,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := rq.modifyResponse(app)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("modifyResponse: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The headers go out with this, the first part of the body,
|
|
||||||
// which Go's server would guess a Content-Type from.
|
|
||||||
_, _ = io.WriteString(w, "<!doctype html><title>page</title>")
|
|
||||||
}))
|
|
||||||
t.Cleanup(srv.Close)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL,
|
|
||||||
http.NoBody)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("new request: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := srv.Client().Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = res.Body.Close()
|
|
||||||
|
|
||||||
if res.Header.Values("Content-Type") != nil {
|
|
||||||
t.Errorf("client got Content-Type %q, want none",
|
|
||||||
res.Header.Get("Content-Type"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -195,151 +194,6 @@ 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,7 +61,6 @@ 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 ` +
|
||||||
@@ -647,10 +646,8 @@ 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. An expires not after the start
|
// each expires is read again as written. A cause other than limit,
|
||||||
// is refused too, since the netblock's next ban for a broken limit, three
|
// attack, admin or crowdsec, most likely misspelt, is refused too.
|
||||||
// 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 {
|
||||||
@@ -671,8 +668,6 @@ 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,21 +865,6 @@ 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()
|
||||||
|
|
||||||
|
|||||||
+6
-49
@@ -5,15 +5,12 @@
|
|||||||
# 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, that a
|
# `docker stop` stops the container without having to kill it, and that
|
||||||
# new container on the same volume still refuses the banned client, that
|
# a new container on the same volume still refuses the banned client. The
|
||||||
# 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 volumes and both images are removed
|
# to GeoJS. The containers, the volume and both images are removed however
|
||||||
# however the script ends. Building the app needs network access, for
|
# the script ends. Building the app needs network access, for nixpkgs'
|
||||||
# nixpkgs' binary cache. script/check does not run this.
|
# 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)"
|
||||||
@@ -26,15 +23,10 @@ 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" "$MOVED_VOLUME" "$SETTINGS_VOLUME" \
|
docker volume rm --force "$VOLUME" >/dev/null 2>&1 || true
|
||||||
>/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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,27 +76,6 @@ 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() {
|
||||||
@@ -169,20 +140,6 @@ 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 "$@"
|
||||||
|
|||||||
@@ -1,335 +0,0 @@
|
|||||||
#!/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 "$@"
|
|
||||||
+3
-23
@@ -5,32 +5,12 @@ 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. When SWWAF_STATE_DIR, in either form, cannot be
|
# never what it points to. exec, so that the signal `sv stop` sends
|
||||||
# read or is not the absolute path of a directory, no owner is changed,
|
# reaches smallwebwaf itself.
|
||||||
# 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
|
||||||
local dir
|
chown -R smallwebwaf:smallwebwaf "${SWWAF_STATE_DIR:-/var/lib/smallwebwaf}"
|
||||||
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