Compare commits
4
Commits
3543a9f616
..
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42e06bf298 | ||
|
|
beb503b233 | ||
|
|
2e2f6f7175 | ||
|
|
7fed6b6d36 |
No files matched your search
+25
-16
@@ -87,7 +87,7 @@ COPY . .
|
|||||||
# .git present, a version that is still empty, dev or unknown fails the
|
# .git present, a version that is still empty, dev or unknown fails the
|
||||||
# build: git is missing or could not read the checkout. The no_fs_access
|
# build: git is missing or could not read the checkout. The no_fs_access
|
||||||
# tag keeps Coraza from writing the files of a multipart body to the
|
# tag keeps Coraza from writing the files of a multipart body to the
|
||||||
# system's temporary directory, since smallwebwaf writes only to its state
|
# system's temporary directory, since sww writes only to its state
|
||||||
# directory.
|
# directory.
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||||
@@ -99,7 +99,7 @@ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
|||||||
fi; \
|
fi; \
|
||||||
CGO_ENABLED=0 go build -tags no_fs_access -trimpath \
|
CGO_ENABLED=0 go build -tags no_fs_access -trimpath \
|
||||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
-o /usr/local/bin/smallwebwaf ./cmd/smallwebwaf
|
-o /usr/local/bin/sww ./cmd/sww
|
||||||
|
|
||||||
# runsvinit, the image's entrypoint, built at the last commit of its
|
# runsvinit, the image's entrypoint, built at the last commit of its
|
||||||
# archived repository. It has no go.mod, and `go build` of its directory
|
# archived repository. It has no go.mod, and `go build` of its directory
|
||||||
@@ -118,7 +118,7 @@ RUN git checkout --quiet --detach b4b2c785308b1ce785b6155c7fe5f16879080193 \
|
|||||||
-o /usr/local/bin/runsvinit .
|
-o /usr/local/bin/runsvinit .
|
||||||
|
|
||||||
# The image, described under "Deployment" in SPEC.md. It runs rproxy unless
|
# The image, described under "Deployment" in SPEC.md. It runs rproxy unless
|
||||||
# SMALLWEBWAF_COMMAND says servewaf, 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.
|
# It is the last stage, so a plain `docker build .` builds it.
|
||||||
#
|
#
|
||||||
# ubuntu 26.04, 2026-09-27
|
# ubuntu 26.04, 2026-09-27
|
||||||
@@ -136,10 +136,19 @@ RUN mkdir /etc/sysusers.d \
|
|||||||
# ca-certificates, nix-bin and runit, from Ubuntu's archive. apt fetches
|
# ca-certificates, nix-bin and runit, from Ubuntu's archive. apt fetches
|
||||||
# them over plain HTTP and checks each one against the archive's signed
|
# them over plain HTTP and checks each one against the archive's signed
|
||||||
# package lists, so no CA certificates are needed.
|
# package lists, so no CA certificates are needed.
|
||||||
RUN apt-get update \
|
#
|
||||||
|
# nix-bin depends on xz-utils, whose install links man pages with
|
||||||
|
# update-alternatives. The image's dpkg config drops man pages, so each
|
||||||
|
# link warns. z-man, read after /etc/dpkg/dpkg.cfg.d/excludes, re-includes
|
||||||
|
# the man1 pages for this install alone; the same step deletes them again,
|
||||||
|
# so no man page or package is added.
|
||||||
|
RUN echo 'path-include=/usr/share/man/man1/*' > /etc/dpkg/dpkg.cfg.d/z-man \
|
||||||
|
&& apt-get update \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
||||||
ca-certificates nix-bin runit \
|
ca-certificates nix-bin runit \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/* /etc/dpkg/dpkg.cfg.d/z-man \
|
||||||
|
&& find /usr/share/man/man1 ! -type d -delete \
|
||||||
|
&& find /etc/alternatives -type l -xtype l -delete
|
||||||
|
|
||||||
# Nix run by root expects a group of build users, which nix-bin does not
|
# Nix run by root expects a group of build users, which nix-bin does not
|
||||||
# create; with the setting empty, root's builds run without them.
|
# create; with the setting empty, root's builds run without them.
|
||||||
@@ -166,27 +175,27 @@ RUN curl -fsSL -o /tmp/nixexprs.tar.xz \
|
|||||||
ENV PATH=${PATH}:/nix/var/nix/profiles/default/bin
|
ENV PATH=${PATH}:/nix/var/nix/profiles/default/bin
|
||||||
|
|
||||||
COPY --from=runsvinit /usr/local/bin/runsvinit /usr/local/bin/runsvinit
|
COPY --from=runsvinit /usr/local/bin/runsvinit /usr/local/bin/runsvinit
|
||||||
COPY --from=builder /usr/local/bin/smallwebwaf /usr/local/bin/smallwebwaf
|
COPY --from=builder /usr/local/bin/sww /usr/local/bin/sww
|
||||||
|
|
||||||
# 65532 is above the uids Ubuntu keeps for system users, which end at
|
# 65532 is above the uids Ubuntu keeps for system users, which end at
|
||||||
# 999; useradd warns about it unless --key raises that end for this call.
|
# 999; useradd warns about it unless --key raises that end for this call.
|
||||||
RUN groupadd --system --gid 65532 smallwebwaf \
|
RUN groupadd --system --gid 65532 sww \
|
||||||
&& useradd --system --key SYS_UID_MAX=65532 --uid 65532 \
|
&& useradd --system --key SYS_UID_MAX=65532 --uid 65532 \
|
||||||
--gid smallwebwaf --no-create-home --shell /usr/sbin/nologin \
|
--gid sww --no-create-home --shell /usr/sbin/nologin \
|
||||||
smallwebwaf
|
sww
|
||||||
|
|
||||||
# The state files' directory, SWWAF_STATE_DIR by default, where a volume
|
# 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
|
# is mounted to keep them across deploys. The run script gives it to the
|
||||||
# smallwebwaf user at each start of servewaf.
|
# sww user at each start of discriminator.
|
||||||
RUN mkdir /var/lib/smallwebwaf
|
RUN mkdir /var/lib/sww
|
||||||
|
|
||||||
# The default rule file, in SWWAF_RULES_DIR by default, where a Dockerfile
|
# The default rule file, in SWW_RULES_DIR by default, where a Dockerfile
|
||||||
# built FROM this image can copy rule files of its own beside it.
|
# built FROM this image can copy rule files of its own beside it.
|
||||||
COPY share/rules.d/00-default.rules /etc/smallwebwaf/rules.d/00-default.rules
|
COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules
|
||||||
|
|
||||||
# runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv
|
# runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv
|
||||||
# looks too.
|
# looks too.
|
||||||
COPY --chmod=755 share/smallwebwaf.run /etc/service/smallwebwaf/run
|
COPY --chmod=755 share/sww.run /etc/service/sww/run
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
@@ -194,6 +203,6 @@ EXPOSE 8080
|
|||||||
# check runs every second from the start until it first passes, for up
|
# check runs every second from the start until it first passes, for up
|
||||||
# to a minute, and every 30 seconds after that.
|
# to a minute, and every 30 seconds after that.
|
||||||
HEALTHCHECK --start-period=1m --start-interval=1s \
|
HEALTHCHECK --start-period=1m --start-interval=1s \
|
||||||
CMD ["/usr/local/bin/smallwebwaf", "healthcheck"]
|
CMD ["/usr/local/bin/sww", "healthcheck"]
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/runsvinit"]
|
ENTRYPOINT ["/usr/local/bin/runsvinit"]
|
||||||
@@ -7,20 +7,20 @@ requests of scrapers, scanners and other abusers before they reach an app.
|
|||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
A web application firewall reads each request on its way to a web app and
|
A web application firewall reads each request on its way to a web app and
|
||||||
refuses the ones that look like abuse. `smallwebwaf` is one binary, shipped in
|
refuses the ones that look like abuse. `smallwebwaf` is one program, `sww`,
|
||||||
one image, with two commands that do this together:
|
shipped in one image, with two commands that do this together:
|
||||||
|
|
||||||
- `smallwebwaf rproxy` is a small reverse proxy in front of one app. traefik
|
- `sww rproxy` is a small reverse proxy in front of one app. traefik routes to
|
||||||
routes to it. It asks `servewaf` about each request, passes the allowed ones
|
it. It asks `discriminator` about each request, passes the allowed ones to the
|
||||||
to the app, and passes the app's answers back. It keeps nothing and never
|
app, and passes the app's answers back. It keeps nothing and never touches
|
||||||
touches disk.
|
disk.
|
||||||
- `smallwebwaf servewaf` decides. It runs the checks and keeps the bans, the
|
- `sww discriminator` decides. It runs the checks and keeps the bans, the
|
||||||
counters and each client's history. One `servewaf` serves many sites: a client
|
counters and each client's history. One `discriminator` serves many sites: a
|
||||||
banned through one site is refused on all of them, and its requests on all of
|
client banned through one site is refused on all of them, and its requests on
|
||||||
them count toward one set of limits.
|
all of them count toward one set of limits.
|
||||||
|
|
||||||
The site keeps answering while `servewaf` is down, slow or restarting: without
|
The site keeps answering while `discriminator` is down, slow or restarting:
|
||||||
an answer in time, `rproxy` passes the request to the app.
|
without an answer in time, `rproxy` passes the request to the app.
|
||||||
|
|
||||||
It protects against:
|
It protects against:
|
||||||
|
|
||||||
@@ -79,11 +79,11 @@ make docker
|
|||||||
|
|
||||||
Push the image to a registry your hosts pull from, and name it there by digest.
|
Push the image to a registry your hosts pull from, and name it there by digest.
|
||||||
|
|
||||||
The image runs `rproxy` by default. To run `servewaf` from the same image, set
|
The image runs `rproxy` by default. To run `discriminator` from the same image,
|
||||||
the container's `SMALLWEBWAF_COMMAND` to `servewaf`. The image's health check
|
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
|
follows the command: in `rproxy`'s container it passes once `rproxy` answers and
|
||||||
the app accepts connections, whether or not `servewaf` answers; in `servewaf`'s
|
the app accepts connections, whether or not `discriminator` answers; in
|
||||||
container, once `servewaf` answers.
|
`discriminator`'s container, once `discriminator` answers.
|
||||||
|
|
||||||
### The usual setup: three containers
|
### The usual setup: three containers
|
||||||
|
|
||||||
@@ -91,24 +91,24 @@ The usual setup is three containers on one private network:
|
|||||||
|
|
||||||
- the app;
|
- the app;
|
||||||
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
|
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
|
||||||
names `servewaf` and `SWRP_UPSTREAM_URL` names the app;
|
names `discriminator` and `SWRP_UPSTREAM_URL` names the app;
|
||||||
- `servewaf`, with `SMALLWEBWAF_COMMAND=servewaf` and a volume at
|
- `discriminator`, with `SWW_COMMAND=discriminator` and a volume at
|
||||||
`/var/lib/smallwebwaf` for its state files.
|
`/var/lib/sww` for its state files.
|
||||||
|
|
||||||
Point traefik's labels at port 8080 of `rproxy`
|
Point traefik's labels at port 8080 of `rproxy`
|
||||||
(`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port
|
(`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port
|
||||||
of the app or of `servewaf`. Anything on that network can reach the app around
|
of the app or of `discriminator`. Anything on that network can reach the app
|
||||||
`rproxy`, and can ask `servewaf` questions, so the network holds only what you
|
around `rproxy`, and can ask `discriminator` questions, so the network holds
|
||||||
trust. Each further app adds its own container and its own `rproxy`, whose
|
only what you trust. Each further app adds its own container and its own
|
||||||
`SWRP_WAF_BACKEND` names the same `servewaf`. The "Smallest setup" example below
|
`rproxy`, whose `SWRP_WAF_BACKEND` names the same `discriminator`. The "Smallest
|
||||||
shows this shape.
|
setup" example below shows this shape.
|
||||||
|
|
||||||
### `rproxy` in the app's container
|
### `rproxy` in the app's container
|
||||||
|
|
||||||
An app's image can instead be built on the `smallwebwaf` image, so that `rproxy`
|
An app's image can instead be built on the `smallwebwaf` image, so that `rproxy`
|
||||||
runs beside the app in one container. `servewaf` still runs in a container of
|
runs beside the app in one container. `discriminator` still runs in a container
|
||||||
its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named by
|
of its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named
|
||||||
digest, and adds:
|
by digest, and adds:
|
||||||
|
|
||||||
- the app's binary;
|
- the app's binary;
|
||||||
- the packages the app needs, from the nixpkgs in the image;
|
- the packages the app needs, from the nixpkgs in the image;
|
||||||
@@ -168,16 +168,16 @@ does.
|
|||||||
- A log line with `"msg":"starting"` gives the version, the address and every
|
- A log line with `"msg":"starting"` gives the version, the address and every
|
||||||
setting in effect, with secrets masked.
|
setting in effect, with secrets masked.
|
||||||
- A request to the site through traefik gets the app's answer.
|
- A request to the site through traefik gets the app's answer.
|
||||||
`https://<site>/_smallwebwaf/healthz` answers `ok`. `servewaf`'s log lines
|
`https://<site>/_sww/healthz` answers `ok`. `discriminator`'s log lines about
|
||||||
about the request have `"action":"forward"`.
|
the request have `"action":"forward"`.
|
||||||
- To see a refusal without banning yourself, run `servewaf` with
|
- To see a refusal without banning yourself, run `discriminator` with
|
||||||
`SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app.
|
`SWW_MODE=observe` and ask for `/.env`. The request reaches the app.
|
||||||
`servewaf`'s log lines have `"would_action":"banned"` and
|
`discriminator`'s log lines have `"would_action":"banned"` and
|
||||||
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
|
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
|
||||||
"Clients and bans" below describes.
|
"Clients and bans" below describes.
|
||||||
- With `SWWAF_METRICS_TOKEN` set, this answers with the metrics, from a host on
|
- With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on
|
||||||
`servewaf`'s network:
|
`discriminator`'s network:
|
||||||
`curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/metrics`.
|
`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
|
- `make example-app` builds the image and an app on it, runs them, and checks
|
||||||
the whole exchange (see "Entrypoints").
|
the whole exchange (see "Entrypoints").
|
||||||
|
|
||||||
@@ -185,16 +185,17 @@ does.
|
|||||||
|
|
||||||
Every setting is an environment variable, read once at start; changing one means
|
Every setting is an environment variable, read once at start; changing one means
|
||||||
restarting the container. `rproxy` reads only the `SWRP_` settings, and
|
restarting the container. `rproxy` reads only the `SWRP_` settings, and
|
||||||
`servewaf` only the `SWWAF_` ones. Every setting but `SWRP_WAF_BACKEND` has a
|
`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
|
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.
|
account, a key, a token or an alert destination stays off until you give one.
|
||||||
|
|
||||||
- Any `SWWAF_` setting can be given as a file instead, for secrets and long
|
- Any `SWW_` setting can be given as a file instead, for secrets and long lists:
|
||||||
lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`.
|
`SWW_ADMIN_TOKEN_FILE` names the file that holds `SWW_ADMIN_TOKEN`. The file's
|
||||||
The file's contents are the value, less one newline at the end. Setting both
|
contents are the value, less one newline at the end. Setting both forms stops
|
||||||
forms stops the start. `SWWAF_LOG_REMOTE_TLS_CA_FILE` already names a file, so
|
the start. `SWW_LOG_REMOTE_TLS_CA_FILE` and
|
||||||
it has no `_FILE` form, and no `SWRP_` setting has one, since `rproxy` never
|
`SWW_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have no
|
||||||
touches disk.
|
`_FILE` form. `SWW_COMMAND` cannot be given as a file. No `SWRP_` setting has
|
||||||
|
one, since `rproxy` never touches disk.
|
||||||
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
|
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
|
||||||
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
|
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
|
||||||
1024: `512K`, `100M`, `5G`.
|
1024: `512K`, `100M`, `5G`.
|
||||||
@@ -215,120 +216,121 @@ account, a key, a token or an alert destination stays off until you give one.
|
|||||||
Every setting the code reads, with its default:
|
Every setting the code reads, with its default:
|
||||||
|
|
||||||
| Variable | Default | What it does |
|
| Variable | Default | What it does |
|
||||||
| ------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `SMALLWEBWAF_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `servewaf`. |
|
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
|
||||||
| `SWRP_WAF_BACKEND` | required | `servewaf`'s URL, such as `http://servewaf:8080`. |
|
| `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_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_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_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 `servewaf`'s answer to a question and to a report, in milliseconds. |
|
| `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 `servewaf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
|
| `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_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`; a larger one is answered `502`, or cut off once it has started. |
|
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`; a larger one is answered `502`, or cut off once it has started. |
|
||||||
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. |
|
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. |
|
||||||
| `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`. |
|
| `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`. |
|
||||||
| `SWWAF_LISTEN_ADDR` | `:8080` | Where `servewaf` listens; leave the host part empty. |
|
| `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
|
||||||
| `SWWAF_INSTANCE_NAME` | the container's host name | The name in every line `servewaf` logs, and in every alert and metric, such as `fsn1/servewaf`. |
|
| `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`. |
|
||||||
| `SWWAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
||||||
| `SWWAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
||||||
| `SWWAF_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
|
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
|
||||||
| `SWWAF_MAX_BANS` | `5000` | The most bans `servewaf` 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. |
|
||||||
| `SWWAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
||||||
| `SWWAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. |
|
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. |
|
||||||
| `SWWAF_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
||||||
| `SWWAF_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. |
|
| `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. |
|
||||||
| `SWWAF_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
|
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
|
||||||
| `SWWAF_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
|
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
|
||||||
| `SWWAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
|
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
|
||||||
| `SWWAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
|
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
|
||||||
| `SWWAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
|
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
|
||||||
| `SWWAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. |
|
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. |
|
||||||
| `SWWAF_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
|
| `SWW_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
|
||||||
| `SWWAF_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. |
|
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. |
|
||||||
| `SWWAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
||||||
| `SWWAF_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
||||||
| `SWWAF_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. |
|
| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. |
|
||||||
| `SWWAF_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
|
| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
|
||||||
| `SWWAF_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
|
| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
|
||||||
| `SWWAF_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
|
| `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
|
||||||
| `SWWAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs`, a web service, `file`, or `off`. |
|
| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs`, a web service, `file`, or `off`. |
|
||||||
| `SWWAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
||||||
| `SWWAF_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
|
| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
|
||||||
| `SWWAF_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
||||||
| `SWWAF_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. |
|
| `SWW_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. |
|
||||||
| `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
||||||
| `SWWAF_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. |
|
| `SWW_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. |
|
||||||
| `SWWAF_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. |
|
| `SWW_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. |
|
||||||
| `SWWAF_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `SWWAF_ASN_LIMIT_PERCENT`. |
|
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `SWW_ASN_LIMIT_PERCENT`. |
|
||||||
| `SWWAF_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWWAF_COUNTRY_LIMIT_PERCENT`. |
|
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWW_COUNTRY_LIMIT_PERCENT`. |
|
||||||
| `SWWAF_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
||||||
| `SWWAF_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; where it and `SWWAF_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. |
|
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; where it and `SWW_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. |
|
||||||
| `SWWAF_RULES_DIR` | `/etc/smallwebwaf/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
||||||
| `SWWAF_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
||||||
| `SWWAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
||||||
| `SWWAF_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
|
| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
|
||||||
| `SWWAF_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
|
| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
|
||||||
| `SWWAF_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
|
| `SWW_WAF_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
|
||||||
| `SWWAF_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
|
| `SWW_WAF_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
|
||||||
| `SWWAF_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
||||||
| `SWWAF_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
||||||
| `SWWAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
|
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
|
||||||
| `SWWAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
|
||||||
| `SWWAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
||||||
| `SWWAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
||||||
| `SWWAF_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
|
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
||||||
| `SWWAF_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
|
| `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
|
||||||
| `SWWAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
|
||||||
| `SWWAF_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
||||||
| `SWWAF_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
|
| `SWW_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
||||||
| `SWWAF_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
|
| `SWW_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
|
||||||
| `SWWAF_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
|
| `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
|
||||||
| `SWWAF_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
| `SWW_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
|
||||||
| `SWWAF_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
| `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
||||||
| `SWWAF_STATE_DIR` | `/var/lib/smallwebwaf` | The directory of the state files, an absolute path. |
|
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
||||||
| `SWWAF_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
|
||||||
| `SWWAF_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
|
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
||||||
| `SWWAF_LOG_LEVEL` | `info` | The least severe of `servewaf`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
|
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
|
||||||
| `SWWAF_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_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
|
||||||
| `SWWAF_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `servewaf` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
|
| `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. |
|
||||||
| `SWWAF_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_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. |
|
||||||
| `SWWAF_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
| `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. |
|
||||||
| `SWWAF_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
||||||
| `SWWAF_LOG_REMOTE_APP_NAME` | `SWWAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
|
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
||||||
| `SWWAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
| `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
|
||||||
| `SWWAF_METRICS_TOKEN` | unset | The bearer token for `/_smallwebwaf/metrics`, at least 32 characters; unset, the metrics are off. |
|
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
||||||
| `SWWAF_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
|
| `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
|
||||||
| `SWWAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
| `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
|
||||||
| `SWWAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
||||||
| `SWWAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
|
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
||||||
| `SWWAF_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
|
| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
|
||||||
| `SWWAF_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
|
| `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
|
||||||
| `SWWAF_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
|
| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
|
||||||
| `SWWAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
|
| `SWW_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
|
||||||
| `SWWAF_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
|
| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
|
||||||
| `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
|
||||||
| `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
||||||
| `SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. |
|
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
|
||||||
| `SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, a size. |
|
| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. |
|
||||||
| `SWWAF_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, a size. |
|
||||||
| `SWWAF_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
|
||||||
| `SWWAF_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. |
|
| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
|
||||||
| `SWWAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, a size. |
|
| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. |
|
||||||
| `SWWAF_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWWAF_ANOMALY_NET_` thresholds count, from 0 to 32. |
|
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, a size. |
|
||||||
| `SWWAF_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWWAF_ANOMALY_NET_` thresholds count, from 0 to 128. |
|
| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
|
||||||
| `SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
|
||||||
| `SWWAF_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
||||||
| `SWWAF_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. |
|
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
|
||||||
| `SWWAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. |
|
| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. |
|
||||||
| `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. |
|
||||||
| `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
||||||
| `SWWAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. |
|
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
|
||||||
| `SWWAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, a size. |
|
| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. |
|
||||||
| `SWWAF_WATCH_NETS` | empty | Named netblocks the `SWWAF_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
|
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, a size. |
|
||||||
| `SWWAF_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
|
||||||
| `SWWAF_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
||||||
| `SWWAF_WATCH_BYTES_PER_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. |
|
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
||||||
| `SWWAF_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this in an hour, a size. |
|
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. |
|
||||||
|
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this in an hour, a size. |
|
||||||
|
|
||||||
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
|
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
|
||||||
full, and the limits that are fixed rather than settings.
|
full, and the limits that are fixed rather than settings.
|
||||||
@@ -338,13 +340,13 @@ full, and the limits that are fixed rather than settings.
|
|||||||
Each example is a complete `compose.yaml`. `smallwebwaf` is the tag
|
Each example is a complete `compose.yaml`. `smallwebwaf` is the tag
|
||||||
`make docker` gives the image; on another host, use the name you pushed it
|
`make docker` gives the image; on another host, use the name you pushed it
|
||||||
under, by digest. Docker Compose puts the services on a private network of its
|
under, by digest. Docker Compose puts the services on a private network of its
|
||||||
own, where each reaches the others by service name. Each publishes `rproxy`'s
|
own, where each reaches the others by service name. Each example publishes
|
||||||
port on the host, for trying it out.
|
`rproxy`'s port on the host, for trying it out.
|
||||||
|
|
||||||
### Smallest setup
|
### Smallest setup
|
||||||
|
|
||||||
One app, `rproxy` in front of it, and `servewaf` with a volume for its state
|
One app, `rproxy` in front of it, and `discriminator` with a volume for its
|
||||||
files, every setting at its default.
|
state files, every setting at its default.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -355,20 +357,20 @@ services:
|
|||||||
rproxy:
|
rproxy:
|
||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SWRP_WAF_BACKEND: http://servewaf:8080
|
SWRP_WAF_BACKEND: http://discriminator:8080
|
||||||
SWRP_UPSTREAM_URL: http://app:8081
|
SWRP_UPSTREAM_URL: http://app:8081
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
servewaf:
|
discriminator:
|
||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SMALLWEBWAF_COMMAND: servewaf
|
SWW_COMMAND: discriminator
|
||||||
volumes:
|
volumes:
|
||||||
- servewaf-state:/var/lib/smallwebwaf
|
- discriminator-state:/var/lib/sww
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
servewaf-state:
|
discriminator-state:
|
||||||
```
|
```
|
||||||
|
|
||||||
### Custom per-address limits
|
### Custom per-address limits
|
||||||
@@ -391,46 +393,46 @@ services:
|
|||||||
rproxy:
|
rproxy:
|
||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SWRP_WAF_BACKEND: http://servewaf:8080
|
SWRP_WAF_BACKEND: http://discriminator:8080
|
||||||
SWRP_UPSTREAM_URL: http://app:8081
|
SWRP_UPSTREAM_URL: http://app:8081
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
servewaf:
|
discriminator:
|
||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SMALLWEBWAF_COMMAND: servewaf
|
SWW_COMMAND: discriminator
|
||||||
# Monitoring skips every check.
|
# Monitoring skips every check.
|
||||||
SWWAF_ALLOW_NETS: 192.0.2.10
|
SWW_ALLOW_NETS: 192.0.2.10
|
||||||
# The office and the automation host are never rate limited.
|
# The office and the automation host are never rate limited.
|
||||||
SWWAF_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
|
SWW_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
|
||||||
# A netblock that sends nothing wanted.
|
# A netblock that sends nothing wanted.
|
||||||
SWWAF_DENY_NETS: 198.51.100.128/25
|
SWW_DENY_NETS: 198.51.100.128/25
|
||||||
# Tighter limits for everyone else: a small static site.
|
# Tighter limits for everyone else: a small static site.
|
||||||
SWWAF_RATE_LIMIT_PER_MINUTE: "60"
|
SWW_RATE_LIMIT_PER_MINUTE: "60"
|
||||||
SWWAF_RATE_LIMIT_PER_HOUR: "600"
|
SWW_RATE_LIMIT_PER_HOUR: "600"
|
||||||
SWWAF_RATE_LIMIT_PER_DAY: "3000"
|
SWW_RATE_LIMIT_PER_DAY: "3000"
|
||||||
# Hosting networks and one country get a share of those limits.
|
# Hosting networks and one country get a share of those limits.
|
||||||
SWWAF_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25
|
SWW_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25
|
||||||
SWWAF_COUNTRY_LIMIT_PERCENT: cn:25
|
SWW_COUNTRY_LIMIT_PERCENT: cn:25
|
||||||
# Paths only scanners ask for; one request bans the client.
|
# Paths only scanners ask for; one request bans the client.
|
||||||
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
||||||
# Answer a refused client with 429 rather than 403.
|
# Answer a refused client with 429 rather than 403.
|
||||||
SWWAF_BAN_RESPONSE: "429"
|
SWW_BAN_RESPONSE: "429"
|
||||||
SWWAF_LIMIT_BAN_DURATION: 30m
|
SWW_LIMIT_BAN_DURATION: 30m
|
||||||
volumes:
|
volumes:
|
||||||
- servewaf-state:/var/lib/smallwebwaf
|
- discriminator-state:/var/lib/sww
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
servewaf-state:
|
discriminator-state:
|
||||||
```
|
```
|
||||||
|
|
||||||
### `rproxy` beside the app
|
### `rproxy` beside the app
|
||||||
|
|
||||||
The app is built on the `smallwebwaf` image, with `rproxy` inside its container.
|
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
|
`app/` holds the `Dockerfile` and `app.run` from "`rproxy` in the app's
|
||||||
container". `rproxy` answers `503` to every request while `servewaf` is down,
|
container". `rproxy` answers `503` to every request while `discriminator` is
|
||||||
and `servewaf`:
|
down, and `discriminator`:
|
||||||
|
|
||||||
- observes before it enforces;
|
- observes before it enforces;
|
||||||
- looks addresses up in a local database rather than GeoJS;
|
- looks addresses up in a local database rather than GeoJS;
|
||||||
@@ -443,46 +445,46 @@ services:
|
|||||||
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
|
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
|
||||||
build: ./app
|
build: ./app
|
||||||
environment:
|
environment:
|
||||||
SWRP_WAF_BACKEND: http://servewaf:8080
|
SWRP_WAF_BACKEND: http://discriminator:8080
|
||||||
# Answer 503 rather than pass a request on while servewaf is down.
|
# Answer 503 rather than pass a request on while discriminator is down.
|
||||||
SWRP_DEFAULT_DISPOSITION: deny
|
SWRP_DEFAULT_DISPOSITION: deny
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
servewaf:
|
discriminator:
|
||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SMALLWEBWAF_COMMAND: servewaf
|
SWW_COMMAND: discriminator
|
||||||
SWWAF_INSTANCE_NAME: fsn1/servewaf
|
SWW_INSTANCE_NAME: fsn1/discriminator
|
||||||
# Refuse nothing yet; log what enforce mode would have done.
|
# Refuse nothing yet; log what enforce mode would have done.
|
||||||
SWWAF_MODE: observe
|
SWW_MODE: observe
|
||||||
# Look addresses up in a local IPinfo Lite database, never GeoJS.
|
# Look addresses up in a local IPinfo Lite database, never GeoJS.
|
||||||
SWWAF_LOOKUP_SOURCE: file
|
SWW_LOOKUP_SOURCE: file
|
||||||
SWWAF_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
|
SWW_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
|
||||||
# Alerts to an ntfy topic, with its token read from a file.
|
# Alerts to an ntfy topic, with its token read from a file.
|
||||||
SWWAF_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
|
SWW_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
|
||||||
SWWAF_ALERT_NTFY_TOKEN_FILE: /etc/smallwebwaf/tokens/ntfy
|
SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
|
||||||
SWWAF_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error
|
SWW_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error
|
||||||
volumes:
|
volumes:
|
||||||
- servewaf-state:/var/lib/smallwebwaf
|
- discriminator-state:/var/lib/sww
|
||||||
- /srv/ipinfo:/var/lib/ipinfo:ro
|
- /srv/ipinfo:/var/lib/ipinfo:ro
|
||||||
- /srv/smallwebwaf/tokens:/etc/smallwebwaf/tokens:ro
|
- /srv/sww/tokens:/etc/sww/tokens:ro
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
servewaf-state:
|
discriminator-state:
|
||||||
```
|
```
|
||||||
|
|
||||||
The database is mounted by its directory, not by file, so that a replaced
|
The database is mounted by its directory, not by file, so that a replaced
|
||||||
database is seen. A token file belongs to uid 65532, the `smallwebwaf` user,
|
database is seen. A token file belongs to uid 65532, the `sww` user, with mode
|
||||||
with mode `0400`. This one holds an access token the ntfy server issued, which
|
`0400`. This one holds an access token the ntfy server issued, which
|
||||||
`ntfy token add <user>` prints there:
|
`ntfy token add <user>` prints there:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
mkdir -p /srv/smallwebwaf/tokens
|
mkdir -p /srv/sww/tokens
|
||||||
# <token> is the access token that ntfy printed.
|
# <token> is the access token that ntfy printed.
|
||||||
echo '<token>' > /srv/smallwebwaf/tokens/ntfy
|
echo '<token>' > /srv/sww/tokens/ntfy
|
||||||
chown 65532:65532 /srv/smallwebwaf/tokens/ntfy
|
chown 65532:65532 /srv/sww/tokens/ntfy
|
||||||
chmod 0400 /srv/smallwebwaf/tokens/ntfy
|
chmod 0400 /srv/sww/tokens/ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
@@ -491,20 +493,20 @@ How it works inside. [`SPEC.md`](SPEC.md) gives every rule in full, and why.
|
|||||||
|
|
||||||
### What happens to a request
|
### What happens to a request
|
||||||
|
|
||||||
A request goes through these steps, in this order. Once `servewaf` refuses a
|
A request goes through these steps, in this order. Once `discriminator` refuses
|
||||||
request, the steps after that are skipped, apart from the report, and nothing of
|
a request, the steps after that are skipped, apart from the report, and nothing
|
||||||
the request reaches the app.
|
of the request reaches the app.
|
||||||
|
|
||||||
`rproxy` takes the first two:
|
`rproxy` takes the first two:
|
||||||
|
|
||||||
1. The health check, `GET /_smallwebwaf/healthz`, is answered at once.
|
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
|
2. The visitor's address is worked out from `X-Forwarded-For`, believed only
|
||||||
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `servewaf` the request as the
|
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `discriminator` the request as
|
||||||
visitor sent it, with the first 64 KiB of its body.
|
the visitor sent it, with the first 64 KiB of its body.
|
||||||
|
|
||||||
`servewaf` takes the checks:
|
`discriminator` takes the checks:
|
||||||
|
|
||||||
3. The static lists, `SWWAF_ALLOW_NETS` and `SWWAF_DENY_NETS`.
|
3. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
|
||||||
4. The bans.
|
4. The bans.
|
||||||
5. The lookup of the client's AS number and country.
|
5. The lookup of the client's AS number and country.
|
||||||
6. The country lists.
|
6. The country lists.
|
||||||
@@ -515,30 +517,30 @@ the request reaches the app.
|
|||||||
10. The trap paths.
|
10. The trap paths.
|
||||||
11. The rule files.
|
11. The rule files.
|
||||||
12. The header limits, and then the Core Rule Set.
|
12. The header limits, and then the Core Rule Set.
|
||||||
13. A path under `/_smallwebwaf/` is answered `404`, so that the app never sees
|
13. A path under `/_sww/` is answered `404`, so that the app never sees that
|
||||||
that prefix.
|
prefix.
|
||||||
|
|
||||||
`rproxy` takes the rest, with `servewaf` counting at the end:
|
`rproxy` takes the rest, with `discriminator` counting at the end:
|
||||||
|
|
||||||
14. The request is passed to the app, streaming.
|
14. The request is passed to the app, streaming.
|
||||||
15. After the answer, `rproxy` sends `servewaf` a report with the status and the
|
15. After the answer, `rproxy` sends `discriminator` a report with the status
|
||||||
bytes each way. For an allowed request, `servewaf` counts the bytes toward
|
and the bytes each way. For an allowed request, `discriminator` counts the
|
||||||
the byte limits and adds the request to the client's history.
|
bytes toward the byte limits and adds the request to the client's history.
|
||||||
|
|
||||||
A refused visitor is answered with `SWWAF_BAN_RESPONSE`. A `block` rule or a
|
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
|
||||||
Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has
|
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
|
||||||
false positives; such refusals count toward `SWWAF_ERROR_BURST_THRESHOLD`
|
positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
|
||||||
instead. A request over the header limits of step 12 is answered `431`, and bans
|
request over the header limits of step 12 is answered `431`, and bans nobody
|
||||||
nobody either. `rproxy` itself answers `405` to a method it does not pass on,
|
either. `rproxy` itself answers `405` to a method it does not pass on, and the
|
||||||
and the status for a size or time limit, such as `413` for a body too large.
|
status for a size or time limit, such as `413` for a body too large.
|
||||||
|
|
||||||
### Clients and bans
|
### Clients and bans
|
||||||
|
|
||||||
- A client is one IPv4 address or one IPv6 netblock of
|
- A client is one IPv4 address or one IPv6 netblock of `SWW_IPV6_GROUP_PREFIX`,
|
||||||
`SWWAF_IPV6_GROUP_PREFIX`, since one abuser usually controls a whole /64.
|
since one abuser usually controls a whole /64.
|
||||||
- Each limit is counted over a sliding window with a few integers per client.
|
- Each limit is counted over a sliding window with a few integers per client.
|
||||||
The table of clients and the table of bans each have a bound
|
The table of clients and the table of bans each have a bound
|
||||||
(`SWWAF_MAX_TRACKED_CLIENTS`, `SWWAF_MAX_BANS`), so memory is bounded too.
|
(`SWW_MAX_TRACKED_CLIENTS`, `SWW_MAX_BANS`), so memory is bounded too.
|
||||||
- A ban refuses every request from its netblock until it ends. Requests refused
|
- A ban refuses every request from its netblock until it ends. Requests refused
|
||||||
under a ban count toward no limit. A permanent ban ends only when an admin
|
under a ban count toward no limit. A permanent ban ends only when an admin
|
||||||
lifts it.
|
lifts it.
|
||||||
@@ -550,25 +552,28 @@ and the status for a size or time limit, such as `413` for a body too large.
|
|||||||
### Attack detection
|
### Attack detection
|
||||||
|
|
||||||
- The Core Rule Set reads the method, the URL with its query string, and the
|
- The Core Rule Set reads the method, the URL with its query string, and the
|
||||||
headers of every request, and no body unless `SWWAF_WAF_BODY_LIMIT` is set. In
|
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
|
||||||
front of a code forge, bodies carry what people write and publish, which the
|
front of a code forge, bodies carry what people write and publish, which the
|
||||||
rules take for attacks.
|
rules take for attacks.
|
||||||
- `smallwebwaf` changes the Core Rule Set in six ways so that it lets ordinary
|
- `smallwebwaf` changes the Core Rule Set in eight ways so that it lets ordinary
|
||||||
requests to a code forge through, such as git over HTTP and sign-in through
|
requests to a code forge through, such as git over HTTP and sign-in through
|
||||||
Git Credential Manager. No setting undoes them; "Attack detection" in
|
Git Credential Manager. On gitea's own paths, its searches, file names and
|
||||||
[`SPEC.md`](SPEC.md) lists them.
|
branch names get through too. No setting undoes the changes; "Attack
|
||||||
|
detection" in [`SPEC.md`](SPEC.md) lists them.
|
||||||
|
- `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
|
- Rule files are plain text, one rule to a line, edited by hand and read again
|
||||||
while `servewaf` runs. The image ships one file of probes no real visitor
|
while `discriminator` runs. The image ships one file of probes no real visitor
|
||||||
sends (see [`docs/rules.md`](docs/rules.md)).
|
sends (see [`docs/rules.md`](docs/rules.md)).
|
||||||
|
|
||||||
### State files
|
### State files
|
||||||
|
|
||||||
`servewaf` keeps everything in memory, and the five JSON files in
|
`discriminator` keeps everything in memory, and the five JSON files in
|
||||||
`SWWAF_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
|
`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
|
`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. `servewaf` notices
|
renamed over the real one, so a crash leaves a complete file. `discriminator`
|
||||||
a file edited on disk and takes the edit in; an edit that does not parse is set
|
notices a file edited on disk and takes the edit in; an edit that does not parse
|
||||||
aside as `<name>.bad`, and never stops the running process (see
|
is set aside as `<name>.bad`, and never stops the running process (see
|
||||||
[`docs/state-files.md`](docs/state-files.md)).
|
[`docs/state-files.md`](docs/state-files.md)).
|
||||||
|
|
||||||
### The image and its processes
|
### The image and its processes
|
||||||
@@ -577,30 +582,30 @@ aside as `<name>.bad`, and never stops the running process (see
|
|||||||
come from Ubuntu's archive, and nixpkgs is fixed at one release. An app's
|
come from Ubuntu's archive, and nixpkgs is fixed at one release. An app's
|
||||||
`Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the
|
`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)).
|
same package each time it is built (see [`docs/image.md`](docs/image.md)).
|
||||||
- runit, a service supervisor, runs `smallwebwaf` as the `smallwebwaf` user (uid
|
- runit, a service supervisor, runs `sww` as the `sww` user (uid and gid 65532)
|
||||||
and gid 65532) and starts it again when it exits. The `run` script of
|
and starts it again when it exits. The `run` script of `discriminator` gives
|
||||||
`servewaf` gives the state directory to the `smallwebwaf` user at each start,
|
the state directory to the `sww` user at each start, so a volume needs no
|
||||||
so a volume needs no change of owner.
|
change of owner.
|
||||||
- `rproxy` and `servewaf` use no token between them. `servewaf`'s own endpoints,
|
- `rproxy` and `discriminator` use no token between them. `discriminator`'s own
|
||||||
under `/_smallwebwaf/`, are for your admin tools and your metrics collector,
|
endpoints, under `/_sww/`, are for your admin tools and your metrics
|
||||||
at its own address on the private network; nothing routes the internet there.
|
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`
|
- `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
|
in the app's container, the app must keep answering for five seconds after
|
||||||
`SIGTERM` for that to help them.
|
`SIGTERM` for that to help them.
|
||||||
- Only faults found at start stop a process: an invalid setting, a rule file or
|
- 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
|
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,
|
address it cannot listen on. Once running, a failing lookup service,
|
||||||
reputation source or alert destination never takes `servewaf` down.
|
reputation source or alert destination never takes `discriminator` down.
|
||||||
|
|
||||||
### Packages
|
### Packages
|
||||||
|
|
||||||
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
|
- `cmd/sww`: the binary, which only calls `internal/sww`.
|
||||||
- `internal/smallwebwaf`: the process, as the command it is given, and the
|
- `internal/sww`: the process, as the command it is given, and the health check.
|
||||||
health check.
|
|
||||||
- `internal/config`: reads the settings, the one place they are read.
|
- `internal/config`: reads the settings, the one place they are read.
|
||||||
- `internal/rproxy`: the reverse proxy in front of one app.
|
- `internal/rproxy`: the reverse proxy in front of one app.
|
||||||
- `internal/servewaf`: `servewaf`'s server, which runs the checks and answers
|
- `internal/discriminator`: `discriminator`'s server, which runs the checks and
|
||||||
its own endpoints.
|
answers its own endpoints.
|
||||||
- `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`,
|
- `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`,
|
||||||
`internal/lookup`, `internal/reputation`, `internal/anomaly` and
|
`internal/lookup`, `internal/reputation`, `internal/anomaly` and
|
||||||
`internal/alerts`: one check or source each.
|
`internal/alerts`: one check or source each.
|
||||||
@@ -664,27 +669,26 @@ on the host.
|
|||||||
image build.
|
image build.
|
||||||
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
|
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
|
||||||
- `script/install-precommit`: installs that hook. `make hooks`.
|
- `script/install-precommit`: installs that hook. `make hooks`.
|
||||||
- `script/build`: builds `bin/smallwebwaf` with the Go on your host.
|
- `script/build`: builds `bin/sww` with the Go on your host. `make build`.
|
||||||
`make build`.
|
- `script/run`: builds `bin/sww` and runs `discriminator` and `rproxy` on the
|
||||||
- `script/run`: builds `bin/smallwebwaf` and runs `servewaf` and `rproxy` on the
|
|
||||||
host, with the settings in your environment. `make run`. Unless your settings
|
host, with the settings in your environment. `make run`. Unless your settings
|
||||||
say otherwise:
|
say otherwise:
|
||||||
- `rproxy` listens on port 8080, in front of an app at
|
- `rproxy` listens on port 8080, in front of an app at
|
||||||
`http://127.0.0.1:8081`;
|
`http://127.0.0.1:8081`;
|
||||||
- `servewaf` listens on port 8090;
|
- `discriminator` listens on port 8090;
|
||||||
- its state files go in `bin/state`;
|
- its state files go in `bin/state`;
|
||||||
- its rule files are those of `share/rules.d`.
|
- its rule files are those of `share/rules.d`.
|
||||||
- `script/example-app`: builds the image and the example app
|
- `script/example-app`: builds the image and the example app
|
||||||
(`deploy/example-app`) on it, runs the app with `rproxy` in its container and
|
(`deploy/example-app`) on it, runs the app with `rproxy` in its container and
|
||||||
`servewaf` in another, and checks them. Then it removes the containers, the
|
`discriminator` in another, and checks them. Then it removes the containers,
|
||||||
network, the volumes and the images. Needs network access. `make example-app`.
|
the network, the volumes and the images. Needs network access.
|
||||||
It checks:
|
`make example-app`. It checks:
|
||||||
- the health checks;
|
- the health checks;
|
||||||
- a request passed to the app;
|
- a request passed to the app;
|
||||||
- a ban for a broken limit, and a ban for a probe made permanent;
|
- a ban for a broken limit, and a ban for a probe made permanent;
|
||||||
- that only `servewaf`'s container changes the state directory's owner;
|
- that only `discriminator`'s container changes the state directory's owner;
|
||||||
- with `servewaf` stopped, the app still answering through `rproxy`;
|
- with `discriminator` stopped, the app still answering through `rproxy`;
|
||||||
- with `servewaf` started again, bans applying again;
|
- with `discriminator` started again, bans applying again;
|
||||||
- orderly stops with `sv stop` and `docker stop`;
|
- orderly stops with `sv stop` and `docker stop`;
|
||||||
- bans surviving a new container on the same volume;
|
- bans surviving a new container on the same volume;
|
||||||
- a setting given as a file.
|
- a setting given as a file.
|
||||||
@@ -703,7 +707,7 @@ on the host.
|
|||||||
- [`docs/settings.md`](docs/settings.md): every setting and the values it takes.
|
- [`docs/settings.md`](docs/settings.md): every setting and the values it takes.
|
||||||
- [`docs/rules.md`](docs/rules.md): the rule file format and the default file.
|
- [`docs/rules.md`](docs/rules.md): the rule file format and the default file.
|
||||||
- [`docs/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under
|
- [`docs/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under
|
||||||
`/_smallwebwaf/`, with examples.
|
`/_sww/`, with examples.
|
||||||
- [`docs/request-log.md`](docs/request-log.md): the request log's fields.
|
- [`docs/request-log.md`](docs/request-log.md): the request log's fields.
|
||||||
- [`docs/alerts.md`](docs/alerts.md): the alert events and what each destination
|
- [`docs/alerts.md`](docs/alerts.md): the alert events and what each destination
|
||||||
gets.
|
gets.
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
// Command smallwebwaf is a web application firewall. `smallwebwaf rproxy` is
|
|
||||||
// the reverse proxy in front of one app, which asks servewaf about each request.
|
|
||||||
// `smallwebwaf servewaf` decides, for many sites, and keeps what lasts.
|
|
||||||
// `smallwebwaf healthcheck` is the container's health check. Everything it does
|
|
||||||
// is in internal/smallwebwaf.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Version is the version of the binary, set when it is built with
|
|
||||||
// -ldflags "-X main.Version=...".
|
|
||||||
var Version = "dev" //nolint:gochecknoglobals // the linker sets it
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
os.Exit(smallwebwaf.Main(Version))
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Command sww is a web application firewall. `sww rproxy` is the reverse
|
||||||
|
// 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
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/sww"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is the version of the binary, set when it is built with
|
||||||
|
// -ldflags "-X main.Version=...".
|
||||||
|
var Version = "dev" //nolint:gochecknoglobals // the linker sets it
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(sww.Main(Version))
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
# A real app names the smallwebwaf image by digest. This one takes the
|
# A real app names the smallwebwaf image by digest. This one takes the
|
||||||
# image script/example-app has just built, or else the one `make docker`
|
# image script/example-app has just built, or else the one `make docker`
|
||||||
# builds.
|
# builds.
|
||||||
ARG SMALLWEBWAF_IMAGE=smallwebwaf
|
ARG SWW_IMAGE=smallwebwaf
|
||||||
FROM ${SMALLWEBWAF_IMAGE}
|
FROM ${SWW_IMAGE}
|
||||||
|
|
||||||
# Packages the app needs, from the nixpkgs in the image.
|
# Packages the app needs, from the nixpkgs in the image.
|
||||||
RUN nix-env -iA nixpkgs.busybox
|
RUN nix-env -iA nixpkgs.busybox
|
||||||
|
|||||||
+26
-26
@@ -1,60 +1,60 @@
|
|||||||
# Admin endpoints
|
# Admin endpoints
|
||||||
|
|
||||||
The endpoints under `/_smallwebwaf/`, what each takes and what each answers.
|
The endpoints under `/_sww/`, what each takes and what each answers.
|
||||||
`smallwebwaf servewaf` answers them at its own address, on the private network.
|
`sww discriminator` answers them at its own address, on the private network.
|
||||||
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work.
|
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work.
|
||||||
|
|
||||||
- `GET /_smallwebwaf/healthz`: `200` and `ok`, to anyone.
|
- `GET /_sww/healthz`: `200` and `ok`, to anyone.
|
||||||
- `GET /_smallwebwaf/metrics`: the metrics, for `SWWAF_METRICS_TOKEN` (see
|
- `GET /_sww/metrics`: the metrics, for `SWW_METRICS_TOKEN` (see
|
||||||
[`metrics.md`](metrics.md)).
|
[`metrics.md`](metrics.md)).
|
||||||
- `GET /_smallwebwaf/bans`: every ban held, under `bans`, each as `bans.json`
|
- `GET /_sww/bans`: every ban held, under `bans`, each as `bans.json` lists it.
|
||||||
lists it. Needs `SWWAF_ADMIN_TOKEN`, as do the three below.
|
Needs `SWW_ADMIN_TOKEN`, as do the three below.
|
||||||
- `POST /_smallwebwaf/bans`: bans a netblock. The body is a JSON object of
|
- `POST /_sww/bans`: bans a netblock. The body is a JSON object of `netblock`,
|
||||||
`netblock`, `duration` and, if you like, `reason`. `netblock` is a netblock
|
`duration` and, if you like, `reason`. `netblock` is a netblock such as
|
||||||
such as `203.0.113.0/24`, or a client's address, which bans its IPv4 netblock
|
`203.0.113.0/24`, or a client's address, which bans its IPv4 netblock
|
||||||
(`SWWAF_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such
|
(`SWW_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such
|
||||||
as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is
|
as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is
|
||||||
wrong:
|
wrong:
|
||||||
- one that is not such an object, or has another field;
|
- one that is not such an object, or has another field;
|
||||||
- one longer than 4 KiB;
|
- one longer than 4 KiB;
|
||||||
- one that names an IPv4-mapped netblock, or an address with a zone.
|
- one that names an IPv4-mapped netblock, or an address with a zone.
|
||||||
- `DELETE /_smallwebwaf/bans/<client>`: lifts every active ban covering the
|
- `DELETE /_sww/bans/<client>`: lifts every active ban covering the address
|
||||||
address `<client>`. It answers `404` when none is active.
|
`<client>`. It answers `404` when none is active.
|
||||||
- `GET /_smallwebwaf/clients/<ip>`: what `servewaf` knows of the client. Under
|
- `GET /_sww/clients/<ip>`: what `discriminator` knows of the client. Under
|
||||||
`client`, its counters and history as `clients.json` holds them, or `null`
|
`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
|
when `clients.json` does not hold it. Under `bans`, every ban covering the
|
||||||
address, with its notes.
|
address, with its notes.
|
||||||
- `POST /_smallwebwaf/report`: takes `rproxy`'s report, without a token. It
|
- `POST /_sww/report`: takes `rproxy`'s report, without a token. It answers
|
||||||
answers `204`, or `400` to a body that is no report (see "The report" in
|
`204`, or `400` to a body that is no report (see "The report" in
|
||||||
[`SPEC.md`](../SPEC.md)).
|
[`SPEC.md`](../SPEC.md)).
|
||||||
|
|
||||||
No check runs on these requests. A missing or wrong token is answered `401`. An
|
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.
|
endpoint whose token is unset answers `404`, as does any other path.
|
||||||
|
|
||||||
`smallwebwaf rproxy` answers only the health check of these, at its own address.
|
`sww rproxy` answers only the health check of these, at its own address. It asks
|
||||||
It asks `servewaf` about any other request under `/_smallwebwaf/`. One that
|
`discriminator` about any other request under `/_sww/`. One that passes the
|
||||||
passes the checks is answered `404`, so that the app never sees that prefix (see
|
checks is answered `404`, so that the app never sees that prefix (see "`rproxy`"
|
||||||
"`rproxy`" in [`SPEC.md`](../SPEC.md)).
|
in [`SPEC.md`](../SPEC.md)).
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
With `SWWAF_ADMIN_TOKEN` set, the token in `$TOKEN`, and `servewaf` reached as
|
With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator` reached
|
||||||
`servewaf:8080` on its network:
|
as `discriminator:8080` on its network:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Every ban.
|
# Every ban.
|
||||||
curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/bans
|
curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/bans
|
||||||
|
|
||||||
# Ban 203.0.113.0/24 for seven days.
|
# Ban 203.0.113.0/24 for seven days.
|
||||||
curl -H "Authorization: Bearer $TOKEN" \
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
--json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \
|
--json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \
|
||||||
http://servewaf:8080/_smallwebwaf/bans
|
http://discriminator:8080/_sww/bans
|
||||||
|
|
||||||
# Lift the bans on 203.0.113.9.
|
# Lift the bans on 203.0.113.9.
|
||||||
curl -H "Authorization: Bearer $TOKEN" -X DELETE \
|
curl -H "Authorization: Bearer $TOKEN" -X DELETE \
|
||||||
http://servewaf:8080/_smallwebwaf/bans/203.0.113.9
|
http://discriminator:8080/_sww/bans/203.0.113.9
|
||||||
|
|
||||||
# What servewaf knows of 203.0.113.9.
|
# What discriminator knows of 203.0.113.9.
|
||||||
curl -H "Authorization: Bearer $TOKEN" \
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
http://servewaf:8080/_smallwebwaf/clients/203.0.113.9
|
http://discriminator:8080/_sww/clients/203.0.113.9
|
||||||
```
|
```
|
||||||
+10
-10
@@ -6,7 +6,7 @@ back or dropped.
|
|||||||
|
|
||||||
## Events
|
## Events
|
||||||
|
|
||||||
- `ban`: a ban `servewaf` made.
|
- `ban`: a ban `discriminator` made.
|
||||||
- `permanent_ban`: a permanent ban it made, or an existing ban made permanent by
|
- `permanent_ban`: a permanent ban it made, or an existing ban made permanent by
|
||||||
a request.
|
a request.
|
||||||
- `waf_block`: the Core Rule Set scored a request at or over the threshold. The
|
- `waf_block`: the Core Rule Set scored a request at or over the threshold. The
|
||||||
@@ -34,7 +34,7 @@ on one line:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"instance": "fsn1/servewaf",
|
"instance": "fsn1/discriminator",
|
||||||
"time": "2026-10-06T12:00:50.123461Z",
|
"time": "2026-10-06T12:00:50.123461Z",
|
||||||
"event": "ban",
|
"event": "ban",
|
||||||
"host": "app.example",
|
"host": "app.example",
|
||||||
@@ -77,11 +77,11 @@ on one line:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
- `instance`: `SWW_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
||||||
one of the events above.
|
one of the events above.
|
||||||
- `host`: the site of the request that raised the alert, since one `servewaf`
|
- `host`: the site of the request that raised the alert, since one
|
||||||
serves many sites. It is left out of `source_failure`, `file_error` and
|
`discriminator` serves many sites. It is left out of `source_failure`,
|
||||||
`summary`.
|
`file_error` and `summary`.
|
||||||
- `client`: the address whose request raised the alert. Empty for
|
- `client`: the address whose request raised the alert. Empty for
|
||||||
`source_failure`, `file_error` and `summary`.
|
`source_failure`, `file_error` and `summary`.
|
||||||
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
|
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
|
||||||
@@ -111,8 +111,8 @@ on one line:
|
|||||||
## Slack and ntfy
|
## Slack and ntfy
|
||||||
|
|
||||||
Slack and ntfy get a message with a title and a text. The title is the instance
|
Slack and ntfy get a message with a title and a text. The title is the instance
|
||||||
and the event, such as `fsn1/servewaf: ban`. The text is the `reason`, then one
|
and the event, such as `fsn1/discriminator: ban`. The text is the `reason`, then
|
||||||
line for each of these that the alert has:
|
one line for each of these that the alert has:
|
||||||
|
|
||||||
- the host, the client, the netblock and the country;
|
- the host, the client, the netblock and the country;
|
||||||
- the file, the source, the error and the mode from `detail`;
|
- the file, the source, the error and the mode from `detail`;
|
||||||
@@ -122,8 +122,8 @@ Slack gets the title in bold and the text below it, with `&`, `<` and `>`
|
|||||||
escaped. ntfy gets the text as the message, with these headers:
|
escaped. ntfy gets the text as the message, with these headers:
|
||||||
|
|
||||||
- `Title`: the title;
|
- `Title`: the title;
|
||||||
- `Authorization`: the token, as `Bearer <token>`, while
|
- `Authorization`: the token, as `Bearer <token>`, while `SWW_ALERT_NTFY_TOKEN`
|
||||||
`SWWAF_ALERT_NTFY_TOKEN` is set;
|
is set;
|
||||||
- `Priority` and `Tags`: by event, as listed below.
|
- `Priority` and `Tags`: by event, as listed below.
|
||||||
|
|
||||||
The priority and tag by event:
|
The priority and tag by event:
|
||||||
|
|||||||
+8
-9
@@ -1,7 +1,7 @@
|
|||||||
# What the image holds
|
# What the image holds
|
||||||
|
|
||||||
The contents of the `smallwebwaf` image, and how each part is pinned. The image
|
The contents of the `smallwebwaf` image, and how each part is pinned. The image
|
||||||
runs `rproxy` by default, or `servewaf` with `SMALLWEBWAF_COMMAND=servewaf`.
|
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
|
"Deployment" in [`SPEC.md`](../SPEC.md) says why each part is there and why it
|
||||||
is pinned that way.
|
is pinned that way.
|
||||||
|
|
||||||
@@ -15,13 +15,12 @@ is pinned that way.
|
|||||||
Unpacked, nixpkgs takes about 500 MiB.
|
Unpacked, nixpkgs takes about 500 MiB.
|
||||||
- `runsvinit` as the entrypoint, built from `github.com/peterbourgon/runsvinit`
|
- `runsvinit` as the entrypoint, built from `github.com/peterbourgon/runsvinit`
|
||||||
at a fixed commit hash.
|
at a fixed commit hash.
|
||||||
- The `smallwebwaf` binary, and the user `smallwebwaf` (uid and gid 65532).
|
- The `sww` binary, and the user `sww` (uid and gid 65532).
|
||||||
- The service directory `/etc/service/smallwebwaf`, with the `run` script
|
- The service directory `/etc/service/sww`, with the `run` script
|
||||||
`share/smallwebwaf.run`.
|
`share/sww.run`.
|
||||||
- `/var/lib/smallwebwaf` for `servewaf`'s state files, and
|
- `/var/lib/sww` for `discriminator`'s state files, and `/etc/sww/rules.d` with
|
||||||
`/etc/smallwebwaf/rules.d` with the default rule file
|
the default rule file `share/rules.d/00-default.rules`.
|
||||||
`share/rules.d/00-default.rules`.
|
- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `sww healthcheck`.
|
||||||
- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `smallwebwaf healthcheck`.
|
|
||||||
|
|
||||||
## Pins
|
## Pins
|
||||||
|
|
||||||
@@ -33,4 +32,4 @@ is pinned that way.
|
|||||||
- `runsvinit`: the archived repository, unchanged since 2015, at its last commit
|
- `runsvinit`: the archived repository, unchanged since 2015, at its last commit
|
||||||
(`b4b2c78`, eight commits after `v2.0.0`), by commit hash, with a `go.mod` the
|
(`b4b2c78`, eight commits after `v2.0.0`), by commit hash, with a `go.mod` the
|
||||||
build writes.
|
build writes.
|
||||||
- The Go image that builds `smallwebwaf`: by digest.
|
- The Go image that builds `sww`: by digest.
|
||||||
+40
-49
@@ -1,90 +1,81 @@
|
|||||||
# The metrics
|
# The metrics
|
||||||
|
|
||||||
What `GET /_smallwebwaf/metrics` answers with, at `servewaf`'s own address.
|
What `GET /_sww/metrics` answers with, at `discriminator`'s own address.
|
||||||
"Metrics endpoint" in [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus
|
"Metrics endpoint" in [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus
|
||||||
keeps the `instance` label as `exported_instance` unless the scrape sets
|
keeps the `instance` label as `exported_instance` unless the scrape sets
|
||||||
`honor_labels: true`. `rproxy` has no metrics.
|
`honor_labels: true`. `rproxy` has no metrics.
|
||||||
|
|
||||||
## Traffic
|
## Traffic
|
||||||
|
|
||||||
- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and
|
- `sww_requests_total`, `sww_request_bytes_total` and
|
||||||
`smallwebwaf_response_bytes_total`, by `status_class` such as `2xx` and by
|
`sww_response_bytes_total`, by `status_class` such as `2xx` and by `action`;
|
||||||
`action`;
|
- `sww_request_duration_seconds` and `sww_upstream_duration_seconds`, as
|
||||||
- `smallwebwaf_request_duration_seconds` and
|
histograms;
|
||||||
`smallwebwaf_upstream_duration_seconds`, as histograms;
|
- `sww_requests_in_flight`: the questions and requests to its own endpoints that
|
||||||
- `smallwebwaf_requests_in_flight`: the questions and requests to its own
|
`discriminator` is handling.
|
||||||
endpoints that `servewaf` is handling.
|
|
||||||
|
|
||||||
## Limits and bans
|
## Limits and bans
|
||||||
|
|
||||||
- `smallwebwaf_rate_limit_hits_total`, by `window` and by `kind` (`requests`,
|
- `sww_rate_limit_hits_total`, by `window` and by `kind` (`requests`, `bytes` or
|
||||||
`bytes` or `refusals`);
|
`refusals`);
|
||||||
- `smallwebwaf_offences_total`, by `kind`;
|
- `sww_offences_total`, by `kind`;
|
||||||
- `smallwebwaf_bans_made_total`, by `cause`;
|
- `sww_bans_made_total`, by `cause`;
|
||||||
- `smallwebwaf_active_bans` and `smallwebwaf_permanent_bans`.
|
- `sww_active_bans` and `sww_permanent_bans`.
|
||||||
|
|
||||||
## Attack detection
|
## Attack detection
|
||||||
|
|
||||||
- `smallwebwaf_rule_matches_total`, by `rule_id` and `action`, and
|
- `sww_rule_matches_total`, by `rule_id` and `action`, and `sww_rules_loaded`;
|
||||||
`smallwebwaf_rules_loaded`;
|
- `sww_waf_matches_total`, by `mode` and `rule_id`, for the rules that fired.
|
||||||
- `smallwebwaf_waf_matches_total`, by `mode` and `rule_id`, for the rules that
|
|
||||||
fired.
|
|
||||||
|
|
||||||
## Countries and AS numbers
|
## Countries and AS numbers
|
||||||
|
|
||||||
For the `SWWAF_METRICS_TOP_N` busiest of each, the rest as `other`:
|
For the `SWW_METRICS_TOP_N` busiest of each, the rest as `other`:
|
||||||
|
|
||||||
- `smallwebwaf_country_requests_total`,
|
- `sww_country_requests_total`, `sww_country_request_bytes_total`,
|
||||||
`smallwebwaf_country_request_bytes_total`,
|
`sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by
|
||||||
`smallwebwaf_country_response_bytes_total` and
|
`country`;
|
||||||
`smallwebwaf_country_list_refusals_total`, by `country`;
|
- `sww_asn_requests_total`, `sww_asn_request_bytes_total` and
|
||||||
- `smallwebwaf_asn_requests_total`, `smallwebwaf_asn_request_bytes_total` and
|
`sww_asn_response_bytes_total`, by `asn`.
|
||||||
`smallwebwaf_asn_response_bytes_total`, by `asn`.
|
|
||||||
|
|
||||||
## Lookups
|
## Lookups
|
||||||
|
|
||||||
- `smallwebwaf_geojs_requests_total` and `smallwebwaf_geojs_failures_total`;
|
- `sww_geojs_requests_total` and `sww_geojs_failures_total`;
|
||||||
- `smallwebwaf_geojs_unanswered_total`: the requests that needed an answer and
|
- `sww_geojs_unanswered_total`: the requests that needed an answer and went on
|
||||||
went on without it;
|
without it;
|
||||||
- with the file source,
|
- with the file source, `sww_lookup_database_last_read_timestamp_seconds` and
|
||||||
`smallwebwaf_lookup_database_last_read_timestamp_seconds` and
|
`sww_lookup_database_read_failures_total`.
|
||||||
`smallwebwaf_lookup_database_read_failures_total`.
|
|
||||||
|
|
||||||
## Reputation sources
|
## Reputation sources
|
||||||
|
|
||||||
By `source`: each list's URL, each zone, and `abuseipdb`:
|
By `source`: each list's URL, each zone, and `abuseipdb`:
|
||||||
|
|
||||||
- `smallwebwaf_reputation_hits_total`, `smallwebwaf_reputation_queries_total`
|
- `sww_reputation_hits_total`, `sww_reputation_queries_total` and
|
||||||
and `smallwebwaf_reputation_failures_total`;
|
`sww_reputation_failures_total`;
|
||||||
- `smallwebwaf_reputation_last_fetch_timestamp_seconds`, for a list;
|
- `sww_reputation_last_fetch_timestamp_seconds`, for a list;
|
||||||
- `smallwebwaf_reputation_daily_budget_remaining`, for AbuseIPDB.
|
- `sww_reputation_daily_budget_remaining`, for AbuseIPDB.
|
||||||
|
|
||||||
## State files
|
## State files
|
||||||
|
|
||||||
By `file`:
|
By `file`:
|
||||||
|
|
||||||
- `smallwebwaf_state_file_writes_total`,
|
- `sww_state_file_writes_total`, `sww_state_file_write_failures_total`,
|
||||||
`smallwebwaf_state_file_write_failures_total`,
|
`sww_state_file_last_write_timestamp_seconds` and `sww_state_file_size_bytes`;
|
||||||
`smallwebwaf_state_file_last_write_timestamp_seconds` and
|
- `sww_state_file_edits_taken_in_total` and
|
||||||
`smallwebwaf_state_file_size_bytes`;
|
`sww_state_file_edits_set_aside_total`;
|
||||||
- `smallwebwaf_state_file_edits_taken_in_total` and
|
- `sww_tracked_clients`: the clients in the table.
|
||||||
`smallwebwaf_state_file_edits_set_aside_total`;
|
|
||||||
- `smallwebwaf_tracked_clients`: the clients in the table.
|
|
||||||
|
|
||||||
## Remote log, alerts and the process
|
## Remote log, alerts and the process
|
||||||
|
|
||||||
- while a syslog server is set, `smallwebwaf_remote_log_lines_sent_total`,
|
- while a syslog server is set, `sww_remote_log_lines_sent_total`,
|
||||||
`smallwebwaf_remote_log_lines_dropped_total` and
|
`sww_remote_log_lines_dropped_total` and `sww_remote_log_buffer_depth`;
|
||||||
`smallwebwaf_remote_log_buffer_depth`;
|
- by `destination`, `sww_alerts_sent_total`, `sww_alerts_failed_total`,
|
||||||
- by `destination`, `smallwebwaf_alerts_sent_total`,
|
`sww_alerts_suppressed_total` and `sww_alerts_dropped_total`;
|
||||||
`smallwebwaf_alerts_failed_total`, `smallwebwaf_alerts_suppressed_total` and
|
|
||||||
`smallwebwaf_alerts_dropped_total`;
|
|
||||||
- Go's own `go_` metrics and the process's `process_` metrics.
|
- Go's own `go_` metrics and the process's `process_` metrics.
|
||||||
|
|
||||||
## When a request is counted
|
## When a request is counted
|
||||||
|
|
||||||
`servewaf` counts a denied request at its decision, and a request to its own
|
`discriminator` counts a denied request at its decision, and a request to its
|
||||||
endpoints when it is answered. It counts an allowed request when its report
|
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:
|
comes, with the status, bytes and times the report gives. These are not counted:
|
||||||
|
|
||||||
- a report itself;
|
- a report itself;
|
||||||
|
|||||||
+37
-36
@@ -1,32 +1,32 @@
|
|||||||
# The request log's fields
|
# The request log's fields
|
||||||
|
|
||||||
The fields of the JSON objects `rproxy` and `servewaf` write about each request.
|
The fields of the JSON objects `rproxy` and `discriminator` write about each
|
||||||
"Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line is
|
request. "Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line
|
||||||
written and how the log is sent to a syslog server. A field that does not apply
|
is written and how the log is sent to a syslog server. A field that does not
|
||||||
to a request is left out.
|
apply to a request is left out.
|
||||||
|
|
||||||
## `servewaf`'s lines
|
## `discriminator`'s lines
|
||||||
|
|
||||||
`smallwebwaf servewaf` writes two lines for each question it answers, joined by
|
`sww discriminator` writes two lines for each question it answers, joined by
|
||||||
`request_id`. The field `servewaf_line` says which of the two a line is:
|
`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;
|
- `decision`: the line it writes once it has answered the question;
|
||||||
- `report`: the line it writes when `rproxy`'s report comes, with everything
|
- `report`: the line it writes when `rproxy`'s report comes, with everything
|
||||||
known. The repetition is intended.
|
known. The repetition is intended.
|
||||||
|
|
||||||
A request to its own endpoints writes one line, without `servewaf_line`. A
|
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.
|
report writes no line but the `report` line of the request it names.
|
||||||
|
|
||||||
These questions write no line:
|
These questions write no line:
|
||||||
|
|
||||||
- a question refused with `400` (see "The question" under "`servewaf`" in
|
- a question refused with `400` (see "The question" under "`discriminator`" in
|
||||||
[`SPEC.md`](../SPEC.md));
|
[`SPEC.md`](../SPEC.md));
|
||||||
- a question whose body stopped arriving, which gets no answer.
|
- a question whose body stopped arriving, which gets no answer.
|
||||||
|
|
||||||
A `report` line:
|
A `report` line:
|
||||||
|
|
||||||
```
|
```
|
||||||
{"type":"request","servewaf_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/servewaf","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},"waf_score":0,"duration_total":3.217,"duration_checks":0.041,"duration_waf":0.029,"duration_upstream_total":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},"waf_score":0,"duration_total":3.217,"duration_checks":0.041,"duration_waf":0.029,"duration_upstream_total":3.104}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Every line
|
### Every line
|
||||||
@@ -41,10 +41,10 @@ Every one of these lines has:
|
|||||||
|
|
||||||
### Standard web log fields
|
### Standard web log fields
|
||||||
|
|
||||||
- `time`: when `servewaf` had read the question's body, RFC 3339 with
|
- `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`
|
milliseconds, in UTC. The `report` line gives the same time as the `decision`
|
||||||
line.
|
line.
|
||||||
- `instance`: `SWWAF_INSTANCE_NAME`.
|
- `instance`: `SWW_INSTANCE_NAME`.
|
||||||
- `client_ip`: the visitor's address, as `rproxy` worked it out.
|
- `client_ip`: the visitor's address, as `rproxy` worked it out.
|
||||||
- `method`, `path` and `query`: the visitor's request.
|
- `method`, `path` and `query`: the visitor's request.
|
||||||
- `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of
|
- `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of
|
||||||
@@ -52,8 +52,9 @@ Every one of these lines has:
|
|||||||
- `host`: the site the visitor asked for.
|
- `host`: the site the visitor asked for.
|
||||||
- `protocol`: the question's own, `HTTP/1.1`.
|
- `protocol`: the question's own, `HTTP/1.1`.
|
||||||
- `status`:
|
- `status`:
|
||||||
- on the `decision` line, what `servewaf` answered: `200` for `allow`, the
|
- on the `decision` line, what `discriminator` answered: `200` for `allow`,
|
||||||
status of `deny`, or `0` for `close`, as the visitor is then sent nothing;
|
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.
|
- on the `report` line, what the visitor got, `0` if nothing.
|
||||||
- `request_bytes`:
|
- `request_bytes`:
|
||||||
- on the `decision` line, how much of the question's body the Core Rule Set
|
- on the `decision` line, how much of the question's body the Core Rule Set
|
||||||
@@ -61,7 +62,7 @@ Every one of these lines has:
|
|||||||
- on the `report` line, the bytes of the visitor's body that `rproxy` read,
|
- on the `report` line, the bytes of the visitor's body that `rproxy` read,
|
||||||
with what a WebSocket carried from the visitor.
|
with what a WebSocket carried from the visitor.
|
||||||
- `response_bytes`:
|
- `response_bytes`:
|
||||||
- on the `decision` line, the body of `servewaf`'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
|
- on the `report` line, the bytes of the answer's body, with what a
|
||||||
WebSocket carried from the app.
|
WebSocket carried from the app.
|
||||||
- `referer` and `user_agent`.
|
- `referer` and `user_agent`.
|
||||||
@@ -78,32 +79,32 @@ Every one of these lines has:
|
|||||||
- lookups are off;
|
- lookups are off;
|
||||||
- the client is not looked up;
|
- the client is not looked up;
|
||||||
- the source cannot place the client;
|
- the source cannot place the client;
|
||||||
- GeoJS had not answered by the time `servewaf` answered.
|
- GeoJS had not answered by the time `discriminator` answered.
|
||||||
- `content_type`: the visitor's `Content-Type`.
|
- `content_type`: the visitor's `Content-Type`.
|
||||||
- `content_length`: the body length the visitor announced.
|
- `content_length`: the body length the visitor announced.
|
||||||
- `request_headers`: the headers listed in `SWWAF_LOG_REQUEST_HEADERS`, by name
|
- `request_headers`: the headers listed in `SWW_LOG_REQUEST_HEADERS`, by name in
|
||||||
in lower case.
|
lower case.
|
||||||
- `has_authorization` and `has_cookie`: true when the request had those headers.
|
- `has_authorization` and `has_cookie`: true when the request had those headers.
|
||||||
Their values are never logged.
|
Their values are never logged.
|
||||||
|
|
||||||
### Response detail
|
### Response detail
|
||||||
|
|
||||||
- `response_content_type`: the type of `servewaf`'s own answer.
|
- `response_content_type`: the type of `discriminator`'s own answer.
|
||||||
- `aborted`: true when `rproxy` went away before the answer.
|
- `aborted`: true when `rproxy` went away before the answer.
|
||||||
|
|
||||||
### Decision
|
### Decision
|
||||||
|
|
||||||
- `action`: what was done:
|
- `action`: what was done:
|
||||||
- `forward`: answered `allow`, so that `rproxy` passes it to the app;
|
- `forward`: answered `allow`, so that `rproxy` passes it to the app;
|
||||||
- `denied`: refused for `SWWAF_DENY_NETS`, or for a reputation source with
|
- `denied`: refused for `SWW_DENY_NETS`, or for a reputation source with the
|
||||||
the action `deny`;
|
action `deny`;
|
||||||
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a
|
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a
|
||||||
CrowdSec decision, each of which bans the client;
|
CrowdSec decision, each of which bans the client;
|
||||||
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
|
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
|
||||||
refused by that check;
|
refused by that check;
|
||||||
- `too_large`: over a header limit;
|
- `too_large`: over a header limit;
|
||||||
- `admin`: answered at one of `servewaf`'s own endpoints, or `404` for a
|
- `admin`: answered at one of `discriminator`'s own endpoints, or `404` for
|
||||||
path under `/_smallwebwaf/`.
|
a path under `/_sww/`.
|
||||||
- `would_action`: in `observe` mode, what `enforce` mode would have done with a
|
- `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
|
request it would have refused. `action` then names what was done, normally
|
||||||
`forward`.
|
`forward`.
|
||||||
@@ -147,8 +148,8 @@ Every one of these lines has:
|
|||||||
All in milliseconds:
|
All in milliseconds:
|
||||||
|
|
||||||
- `duration_total`:
|
- `duration_total`:
|
||||||
- on the `decision` line, from `servewaf` having read the question's body to
|
- on the `decision` line, from `discriminator` having read the question's
|
||||||
the line;
|
body to the line;
|
||||||
- on the `report` line, from `rproxy` having read the request's headers to
|
- on the `report` line, from `rproxy` having read the request's headers to
|
||||||
the end of the answer.
|
the end of the answer.
|
||||||
- `duration_checks`: to the end of the checks;
|
- `duration_checks`: to the end of the checks;
|
||||||
@@ -158,8 +159,8 @@ All in milliseconds:
|
|||||||
|
|
||||||
### Requests to its own endpoints
|
### Requests to its own endpoints
|
||||||
|
|
||||||
The line of a request to `servewaf`'s own endpoints has these fields, with these
|
The line of a request to `discriminator`'s own endpoints has these fields, with
|
||||||
meanings where they differ:
|
these meanings where they differ:
|
||||||
|
|
||||||
- `client_ip` and `peer_ip`: the request's TCP peer.
|
- `client_ip` and `peer_ip`: the request's TCP peer.
|
||||||
- `request_id`: a new random one of 26 letters and digits.
|
- `request_id`: a new random one of 26 letters and digits.
|
||||||
@@ -171,8 +172,8 @@ meanings where they differ:
|
|||||||
|
|
||||||
## `rproxy`'s line
|
## `rproxy`'s line
|
||||||
|
|
||||||
`smallwebwaf rproxy` writes one short line for each request. `request_id` joins
|
`sww rproxy` writes one short line for each request. `request_id` joins it to
|
||||||
it to `servewaf`'s two. It has these fields and no others:
|
`discriminator`'s two. It has these fields and no others:
|
||||||
|
|
||||||
- `type`: `request`.
|
- `type`: `request`.
|
||||||
- `time`: when the request arrived, RFC 3339 with milliseconds, in UTC.
|
- `time`: when the request arrived, RFC 3339 with milliseconds, in UTC.
|
||||||
@@ -187,15 +188,15 @@ it to `servewaf`'s two. It has these fields and no others:
|
|||||||
carried from the app.
|
carried from the app.
|
||||||
- `duration_request`: milliseconds from the request's headers being read until
|
- `duration_request`: milliseconds from the request's headers being read until
|
||||||
the app had been sent the whole request, or the answer started if that was
|
the app had been sent the whole request, or the answer started if that was
|
||||||
sooner. It includes the wait for `servewaf`.
|
sooner. It includes the wait for `discriminator`.
|
||||||
- `duration_response`: milliseconds from then until the end of the answer.
|
- `duration_response`: milliseconds from then until the end of the answer.
|
||||||
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It
|
- `disposition`: what was done with the request: `allow`, `deny` or `close`. It
|
||||||
is `servewaf`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is left
|
is `discriminator`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is
|
||||||
out when:
|
left out when:
|
||||||
- `servewaf` was not asked, as for the health check;
|
- `discriminator` was not asked, as for the health check;
|
||||||
- the visitor went away while `servewaf` was being asked;
|
- the visitor went away while `discriminator` was being asked;
|
||||||
- `servewaf`'s answer had no valid `X-Smallwebwaf-Disposition`;
|
- `discriminator`'s answer had no valid `X-Sww-Disposition`;
|
||||||
- `rproxy` could not pass on `servewaf`'s answer, and answered `502`.
|
- `rproxy` could not pass on `discriminator`'s answer, and answered `502`.
|
||||||
- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave
|
- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave
|
||||||
`disposition`, and otherwise left out.
|
`disposition`, and otherwise left out.
|
||||||
|
|
||||||
|
|||||||
+8
-7
@@ -32,7 +32,7 @@ scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
|
|||||||
## The default file
|
## The default file
|
||||||
|
|
||||||
The image ships one file, `share/rules.d/00-default.rules`, installed as
|
The image ships one file, `share/rules.d/00-default.rules`, installed as
|
||||||
`/etc/smallwebwaf/rules.d/00-default.rules`:
|
`/etc/sww/rules.d/00-default.rules`:
|
||||||
|
|
||||||
```
|
```
|
||||||
# 00-default.rules: probes no real visitor sends, anchored at the site root
|
# 00-default.rules: probes no real visitor sends, anchored at the site root
|
||||||
@@ -54,19 +54,20 @@ empty-agent user_agent log ^$
|
|||||||
|
|
||||||
## A file of your own
|
## A file of your own
|
||||||
|
|
||||||
`servewaf` reads the rule files in its own container. Rules of your own go in a
|
`discriminator` reads the rule files in its own container. Rules of your own go
|
||||||
file named to sort after the default, such as `50-site.rules`:
|
in a file named to sort after the default, such as `50-site.rules`:
|
||||||
|
|
||||||
```
|
```
|
||||||
# 50-site.rules: WordPress probes, which this site never serves
|
# 50-site.rules: WordPress probes, which this site never serves
|
||||||
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
|
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
|
||||||
```
|
```
|
||||||
|
|
||||||
A `Dockerfile` built on the `smallwebwaf` image, for `servewaf` to run from,
|
A `Dockerfile` built on the `smallwebwaf` image, for `discriminator` to run
|
||||||
adds it:
|
from, adds it:
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
COPY 50-site.rules /etc/smallwebwaf/rules.d/50-site.rules
|
COPY 50-site.rules /etc/sww/rules.d/50-site.rules
|
||||||
```
|
```
|
||||||
|
|
||||||
Or mount it there. A `path` rule applies on every site behind that `servewaf`.
|
Or mount it there. A `path` rule applies on every site behind that
|
||||||
|
`discriminator`.
|
||||||
+177
-169
@@ -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
|
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.
|
that take `off`, the `_FILE` form, and what each group does and why.
|
||||||
|
|
||||||
The settings that start with `SWWAF_` are `servewaf`'s. Those that start with
|
The settings that start with `SWW_` are `discriminator`'s. Those that start with
|
||||||
`SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SMALLWEBWAF_COMMAND`
|
`SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND` chooses
|
||||||
chooses between the two (see "The image's command" below).
|
between the two (see "The image's command" below).
|
||||||
|
|
||||||
## Forms of values
|
## Forms of values
|
||||||
|
|
||||||
@@ -28,243 +28,250 @@ chooses between the two (see "The image's command" below).
|
|||||||
|
|
||||||
## Core
|
## Core
|
||||||
|
|
||||||
- `SWWAF_LISTEN_ADDR` (default `:8080`): where `servewaf` listens: `:` and a
|
- `SWW_LISTEN_ADDR` (default `:8080`): where `discriminator` listens: `:` and a
|
||||||
port, such as `:9000`. Leave the host out (see "Ports" in
|
port, such as `:9000`. Leave the host out (see "Ports" in
|
||||||
[`SPEC.md`](../SPEC.md)).
|
[`SPEC.md`](../SPEC.md)).
|
||||||
- `SWWAF_INSTANCE_NAME` (default: the container's host name): the `instance`
|
- `SWW_INSTANCE_NAME` (default: the container's host name): the `instance` name
|
||||||
name in each line `servewaf` logs, and in each alert and metric, such as
|
in each line `discriminator` logs, and in each alert and metric, such as
|
||||||
`fsn1/servewaf`. Valid UTF-8. No control character while
|
`fsn1/discriminator`. Valid UTF-8. No control character while
|
||||||
`SWWAF_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a
|
`SWW_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a
|
||||||
space while `SWWAF_LOG_REMOTE_URL` is set and `SWWAF_LOG_REMOTE_APP_NAME` is
|
space while `SWW_LOG_REMOTE_URL` is set and `SWW_LOG_REMOTE_APP_NAME` is not.
|
||||||
not.
|
- `SWW_MODE` (default `enforce`): `enforce` or `observe`.
|
||||||
- `SWWAF_MODE` (default `enforce`): `enforce` or `observe`.
|
- `SWW_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 client's
|
||||||
- `SWWAF_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6
|
group, from 32 to 128.
|
||||||
client's group, from 32 to 128.
|
- `SWW_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
|
||||||
- `SWWAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
|
|
||||||
number above zero.
|
number above zero.
|
||||||
- `SWWAF_MAX_BANS` (default `5000`): how many of its own bans `servewaf` keeps,
|
- `SWW_MAX_BANS` (default `5000`): how many of its own bans `discriminator`
|
||||||
a whole number above zero.
|
keeps, a whole number above zero.
|
||||||
|
|
||||||
## Tokens
|
## Tokens
|
||||||
|
|
||||||
- `SWWAF_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
|
- `SWW_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
|
||||||
`/_smallwebwaf/clients/<ip>`. At least 32 characters.
|
`/_sww/clients/<ip>`. At least 32 characters.
|
||||||
- `SWWAF_METRICS_TOKEN` (default unset): the token for `/_smallwebwaf/metrics`.
|
- `SWW_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At least
|
||||||
At least 32 characters.
|
32 characters.
|
||||||
|
|
||||||
## State files
|
## State files
|
||||||
|
|
||||||
- `SWWAF_STATE_DIR` (default `/var/lib/smallwebwaf`): the directory of the state
|
- `SWW_STATE_DIR` (default `/var/lib/sww`): the directory of the state files, an
|
||||||
files, an absolute path that exists and can be written.
|
absolute path that exists and can be written.
|
||||||
- `SWWAF_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
- `SWW_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
||||||
written after a ban is made, lifted or made permanent. A duration.
|
written after a ban is made, lifted or made permanent. A duration.
|
||||||
- `SWWAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
|
- `SWW_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
|
||||||
written. A duration.
|
written. A duration.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
- `SWWAF_LOG_LEVEL` (default `info`): the least severe of `servewaf`'s own
|
- `SWW_LOG_LEVEL` (default `info`): the least severe of `discriminator`'s own
|
||||||
messages that are written: `debug`, `info`, `warn` or `error`.
|
messages that are written: `debug`, `info`, `warn` or `error`.
|
||||||
- `SWWAF_LOG_REQUEST_HEADERS` (default
|
- `SWW_LOG_REQUEST_HEADERS` (default
|
||||||
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
||||||
request headers recorded in the log line, in either case. `Host` and
|
request headers recorded in the log line, in either case. `Host` and
|
||||||
`Transfer-Encoding` are refused; the host is the field `host`.
|
`Transfer-Encoding` are refused; the host is the field `host`.
|
||||||
- `SWWAF_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
|
- `SWW_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
|
||||||
line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`,
|
line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`,
|
||||||
each with a host and a port and nothing more.
|
each with a host and a port and nothing more.
|
||||||
- `SWWAF_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates
|
- `SWW_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates that
|
||||||
that a `syslog+tls` server's certificate must chain to, instead of the host's
|
a `syslog+tls` server's certificate must chain to, instead of the host's own.
|
||||||
own. A file that cannot be read, or holds no certificate, is refused.
|
A file that cannot be read, or holds no certificate, is refused.
|
||||||
- `SWWAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they
|
- `SWW_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they wait
|
||||||
wait to be sent, a whole number above zero.
|
to be sent, a whole number above zero.
|
||||||
- `SWWAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
- `SWW_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
||||||
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
|
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
|
||||||
`authpriv`, `ftp`, or `local0` to `local7`.
|
`authpriv`, `ftp`, or `local0` to `local7`.
|
||||||
- `SWWAF_LOG_REMOTE_APP_NAME` (default: `SWWAF_INSTANCE_NAME`): the app name in
|
- `SWW_LOG_REMOTE_APP_NAME` (default: `SWW_INSTANCE_NAME`): the app name in each
|
||||||
each syslog record, 1 to 48 printable ASCII characters without a space.
|
syslog record, 1 to 48 printable ASCII characters without a space.
|
||||||
|
|
||||||
## Metrics
|
## Metrics
|
||||||
|
|
||||||
- `SWWAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many
|
- `SWW_METRICS_TOP_N` (default `50`): how many AS numbers and how many countries
|
||||||
countries get series of their own. A whole number above zero.
|
get series of their own. A whole number above zero.
|
||||||
|
|
||||||
## Static lists
|
## Static lists
|
||||||
|
|
||||||
- `SWWAF_ALLOW_NETS` (default empty): netblocks you trust, such as your
|
- `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring
|
||||||
monitoring (see step 3 in [`SPEC.md`](../SPEC.md)).
|
(see step 3 in [`SPEC.md`](../SPEC.md)).
|
||||||
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
- `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
||||||
exempt from rate limiting and the byte limits.
|
exempt from rate limiting and the byte limits.
|
||||||
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are refused (see
|
- `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step
|
||||||
step 3 in [`SPEC.md`](../SPEC.md)).
|
3 in [`SPEC.md`](../SPEC.md)).
|
||||||
|
|
||||||
## Request rate limits, per client
|
## Request rate limits, per client
|
||||||
|
|
||||||
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
|
- `SWW_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
|
||||||
client may make in a minute. A count or `off`. The default leaves room for a
|
client may make in a minute. A count or `off`. The default leaves room for a
|
||||||
person opening two pages at once with an empty cache, up to 60 files each.
|
person opening two pages at once with an empty cache, up to 60 files each.
|
||||||
- `SWWAF_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
|
- `SWW_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
|
||||||
make in an hour. A count or `off`. It is the main limit against scrapers. A
|
make in an hour. A count or `off`. It is the main limit against scrapers. A
|
||||||
person reading a site for an hour, a page every 20 seconds, makes about 900. A
|
person reading a site for an hour, a page every 20 seconds, makes about 900. A
|
||||||
scraper making one request every 2 seconds makes 1,800 an hour, more than the
|
scraper making one request every 2 seconds makes 1,800 an hour, more than the
|
||||||
limit.
|
limit.
|
||||||
- `SWWAF_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may
|
- `SWW_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may make
|
||||||
make in a day. A count or `off`. The default is four hours at the hour limit.
|
in a day. A count or `off`. The default is four hours at the hour limit. A
|
||||||
A scraper making one request every 10 seconds makes 8,640 a day, more than the
|
scraper making one request every 10 seconds makes 8,640 a day, more than the
|
||||||
limit.
|
limit.
|
||||||
- A CI runner asks its code forge for work all day and can pass the limits. Put
|
- A CI runner asks its code forge for work all day and can pass the limits. Put
|
||||||
the runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
|
the runners' addresses in `SWW_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
|
||||||
network where many people share one address, such as an office.
|
network where many people share one address, such as an office.
|
||||||
- Counting cannot catch a client that stays under all three limits (see "Request
|
- Counting cannot catch a client that stays under all three limits (see "Request
|
||||||
rate limits, per client" in [`SPEC.md`](../SPEC.md)).
|
rate limits, per client" in [`SPEC.md`](../SPEC.md)).
|
||||||
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
|
- `SWW_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from rate
|
||||||
rate limiting and the byte limits, such as `/assets/`.
|
limiting and the byte limits, such as `/assets/`.
|
||||||
|
|
||||||
## Byte limits, per client
|
## Byte limits, per client
|
||||||
|
|
||||||
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWWAF_BYTES_LIMIT_PER_HOUR`
|
- `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR`
|
||||||
(default `50G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most
|
(default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes
|
||||||
bytes counted for a client in each window. A size or `off`.
|
counted for a client in each window. A size or `off`.
|
||||||
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
|
- `SWW_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
|
||||||
`response`, `request` or `both`.
|
`response`, `request` or `both`.
|
||||||
|
|
||||||
## Lookup of AS number and country
|
## Lookup of AS number and country
|
||||||
|
|
||||||
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
|
- `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
|
||||||
the start while a setting that needs a lookup is set:
|
the start while a setting that needs a lookup is set:
|
||||||
- the two country lists;
|
- the two country lists;
|
||||||
- `SWWAF_ADD_LOOKUP_HEADERS`;
|
- `SWW_ADD_LOOKUP_HEADERS`;
|
||||||
- the four biased threshold lists and `SWWAF_ASN_LIMIT_PERCENT_URL`;
|
- the four biased threshold lists and `SWW_ASN_LIMIT_PERCENT_URL`;
|
||||||
- `SWWAF_UNKNOWN_LIMIT_PERCENT` below `100`;
|
- `SWW_UNKNOWN_LIMIT_PERCENT` below `100`;
|
||||||
- an anomaly threshold per AS number.
|
- an anomaly threshold per AS number.
|
||||||
- `SWWAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
|
- `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
|
||||||
`.mmdb` form, for `SWWAF_LOOKUP_SOURCE=file`.
|
`.mmdb` form, for `SWW_LOOKUP_SOURCE=file`.
|
||||||
- `SWWAF_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
|
- `SWW_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
|
||||||
client's first answer from GeoJS, and how long a request to GeoJS may take. A
|
client's first answer from GeoJS, and how long a request to GeoJS may take. A
|
||||||
duration; never `off`.
|
duration; never `off`.
|
||||||
- `SWWAF_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app
|
- `SWW_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app
|
||||||
the client's AS number in `X-Client-ASN` and its country in
|
the client's AS number in `X-Client-ASN` and its country in
|
||||||
`X-Client-Country`.
|
`X-Client-Country`.
|
||||||
|
|
||||||
## Country lists
|
## Country lists
|
||||||
|
|
||||||
- `SWWAF_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
|
- `SWW_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
|
||||||
such as `cn,ru,kp`.
|
such as `cn,ru,kp`.
|
||||||
- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
||||||
countries whose clients get through, such as `us,de`. A country on both lists
|
countries whose clients get through, such as `us,de`. A country on both lists
|
||||||
stops the start.
|
stops the start.
|
||||||
|
|
||||||
## Biased thresholds
|
## Biased thresholds
|
||||||
|
|
||||||
- `SWWAF_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:`
|
- `SWW_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:`
|
||||||
and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number
|
and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number
|
||||||
listed twice is refused.
|
listed twice is refused.
|
||||||
- `SWWAF_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as
|
- `SWW_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as
|
||||||
`cn:25,ru:50`.
|
`cn:25,ru:50`.
|
||||||
- `SWWAF_ASN_BYTES_PERCENT` and `SWWAF_COUNTRY_BYTES_PERCENT` (default empty):
|
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): the
|
||||||
the same, for the byte limits alone.
|
same, for the byte limits alone.
|
||||||
- `SWWAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
|
- `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
|
||||||
lookup cannot place.
|
lookup cannot place.
|
||||||
- `SWWAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
||||||
text file with one AS number and percentage on each line, such as
|
text file with one AS number and percentage on each line, such as
|
||||||
`AS64496:50`. A URL that is also in `SWWAF_BLOCKLIST_URLS` is refused.
|
`AS64496:50`. A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
|
||||||
|
|
||||||
## Attack detection
|
## Attack detection
|
||||||
|
|
||||||
- `SWWAF_RULES_DIR` (default `/etc/smallwebwaf/rules.d`): the directory of the
|
- `SWW_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule files.
|
||||||
rule files.
|
- `SWW_RULES_ENABLED` (default `true`): `false` reads no rule file.
|
||||||
- `SWWAF_RULES_ENABLED` (default `true`): `false` reads no rule file.
|
- `SWW_TRAP_PATHS` (default empty): paths only scanners ask for, such as
|
||||||
- `SWWAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as
|
|
||||||
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
|
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
|
||||||
- `SWWAF_WAF_MODE` (default `block`): `off`, `detect` or `block`.
|
- `SWW_WAF_MODE` (default `block`): `off`, `detect` or `block`.
|
||||||
- `SWWAF_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level,
|
- `SWW_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level,
|
||||||
from 1 to 4.
|
from 1 to 4.
|
||||||
- `SWWAF_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a
|
- `SWW_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a
|
||||||
match. A whole number above zero, or `off`.
|
match. A whole number above zero, or `off`.
|
||||||
- `SWWAF_WAF_DISABLED_RULES` (default
|
- `SWW_WAF_DISABLED_RULES` (default
|
||||||
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
|
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
|
||||||
to switch off. Each a whole number above zero. An id from 900000 to 900999 is
|
to switch off. Each a whole number above zero. An id from 900000 to 900999 is
|
||||||
refused: those are the rules that set the Core Rule Set up, and
|
refused: those are the rules that set the Core Rule Set up, and
|
||||||
`smallwebwaf`'s own.
|
`smallwebwaf`'s own.
|
||||||
- `SWWAF_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does
|
- `SWW_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does
|
||||||
not inspect.
|
not inspect.
|
||||||
- `SWWAF_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body
|
- `SWW_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body the
|
||||||
the Core Rule Set reads, a size of at most `64K`. A question carries no more
|
Core Rule Set reads, a size of at most `64K`. A question carries no more of a
|
||||||
of a body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one
|
body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one byte of
|
||||||
byte of it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
|
it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
|
||||||
- `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client
|
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set
|
||||||
in a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
|
exclusions, written as Coraza directives. It is loaded before the Core Rule
|
||||||
|
Set's rules, so it holds only `SecAction` and `SecRule` lines that exclude
|
||||||
|
with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that cannot be
|
||||||
|
read or does not parse stops the start, whatever `SWW_WAF_MODE` is. So does a
|
||||||
|
file that names a number from 900000 to 900999 outside a comment, alone or in
|
||||||
|
a range. So does a directive that removes or changes rules as the file loads,
|
||||||
|
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
|
||||||
|
yet. It has no `_FILE` form.
|
||||||
|
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client in
|
||||||
|
a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
|
||||||
|
|
||||||
## Ban lengths, response and scope
|
## Ban lengths, response and scope
|
||||||
|
|
||||||
- `SWWAF_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
|
- `SWW_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
|
||||||
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A
|
- `SWW_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A
|
||||||
duration.
|
duration.
|
||||||
- `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
|
- `SWW_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
|
||||||
within which breaking a limit again lengthens the next ban. A duration.
|
within which breaking a limit again lengthens the next ban. A duration.
|
||||||
- `SWWAF_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit;
|
- `SWW_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit; one
|
||||||
one that would be longer is permanent. A duration.
|
that would be longer is permanent. A duration.
|
||||||
- `SWWAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
- `SWW_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
||||||
attack. A duration.
|
attack. A duration.
|
||||||
- `SWWAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
- `SWW_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
||||||
covers: the prefix length of its netblock, from 0 to 32.
|
covers: the prefix length of its netblock, from 0 to 32.
|
||||||
|
|
||||||
## Reputation
|
## Reputation
|
||||||
|
|
||||||
- `SWWAF_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
|
- `SWW_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
|
||||||
`https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL
|
`https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL
|
||||||
without a user or a fragment. A URL listed twice is refused.
|
without a user or a fragment. A URL listed twice is refused.
|
||||||
- `SWWAF_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its
|
- `SWW_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its last
|
||||||
last try before it is fetched again. A duration of `1h` or more.
|
try before it is fetched again. A duration of `1h` or more.
|
||||||
- `SWWAF_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`.
|
- `SWW_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`.
|
||||||
- `SWWAF_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as
|
- `SWW_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as
|
||||||
`dnsbl.dronebl.org`. Each a DNS name of at most 189 characters. A Spamhaus
|
`dnsbl.dronebl.org`. Each a DNS name of at most 189 characters. A Spamhaus
|
||||||
zone carries its key, as in `<key>.xbl.dq.spamhaus.net`. A zone listed twice
|
zone carries its key, as in `<key>.xbl.dq.spamhaus.net`. A zone listed twice
|
||||||
is refused, whatever its case or key.
|
is refused, whatever its case or key.
|
||||||
- `SWWAF_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through,
|
- `SWW_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through,
|
||||||
such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional
|
such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional
|
||||||
port, 53 when none is given. Unset, the host's resolver is used.
|
port, 53 when none is given. Unset, the host's resolver is used.
|
||||||
- `SWWAF_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
|
- `SWW_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
|
||||||
control character in it is refused.
|
control character in it is refused.
|
||||||
- `SWWAF_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
|
- `SWW_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
|
||||||
from 0 to 100, that is a hit.
|
from 0 to 100, that is a hit.
|
||||||
- `SWWAF_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day,
|
- `SWW_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day, a
|
||||||
a whole number above zero. AbuseIPDB's free accounts may make 1,000.
|
whole number above zero. AbuseIPDB's free accounts may make 1,000.
|
||||||
- `SWWAF_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine
|
- `SWW_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine
|
||||||
you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a
|
you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a
|
||||||
user or a fragment; its decision list is at `v1/decisions` under it. Set
|
user or a fragment; its decision list is at `v1/decisions` under it. Set
|
||||||
without the key, it is refused. So is a decision list URL that is also in
|
without the key, it is refused. So is a decision list URL that is also in
|
||||||
`SWWAF_BLOCKLIST_URLS`, or that is `SWWAF_ASN_LIMIT_PERCENT_URL`.
|
`SWW_BLOCKLIST_URLS`, or that is `SWW_ASN_LIMIT_PERCENT_URL`.
|
||||||
- `SWWAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
- `SWW_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
||||||
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
|
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
|
||||||
URL, it is refused.
|
URL, it is refused.
|
||||||
- `SWWAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
|
- `SWW_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
|
||||||
`log`, for DNS blocklist and AbuseIPDB hits.
|
`log`, for DNS blocklist and AbuseIPDB hits.
|
||||||
- `SWWAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
|
- `SWW_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
|
||||||
AbuseIPDB's score is used. A duration.
|
AbuseIPDB's score is used. A duration.
|
||||||
- `SWWAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a
|
- `SWW_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a check
|
||||||
check with AbuseIPDB may take. A duration.
|
with AbuseIPDB may take. A duration.
|
||||||
|
|
||||||
## Alerting
|
## Alerting
|
||||||
|
|
||||||
- `SWWAF_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each
|
- `SWW_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each alert
|
||||||
alert as a JSON object. An `http` or `https` URL without a user or a fragment.
|
as a JSON object. An `http` or `https` URL without a user or a fragment.
|
||||||
- `SWWAF_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post,
|
- `SWW_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post, such
|
||||||
such as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and
|
as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and value. A
|
||||||
value. A value cannot hold a comma.
|
value cannot hold a comma.
|
||||||
- `SWWAF_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook
|
- `SWW_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook that
|
||||||
that receives each alert as a message, checked as the webhook URL is.
|
receives each alert as a message, checked as the webhook URL is.
|
||||||
- `SWWAF_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each
|
- `SWW_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each alert,
|
||||||
alert, as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked
|
as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked as the
|
||||||
as the webhook URL is.
|
webhook URL is.
|
||||||
- `SWWAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
|
- `SWW_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
|
||||||
character in it is refused.
|
character in it is refused.
|
||||||
- `SWWAF_ALERT_EVENTS` (default
|
- `SWW_ALERT_EVENTS` (default
|
||||||
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
|
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
|
||||||
the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
|
the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
|
||||||
but `summary`. Any other name is refused.
|
but `summary`. Any other name is refused.
|
||||||
- `SWWAF_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
|
- `SWW_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
|
||||||
repeats are held back. A duration or `off`.
|
repeats are held back. A duration or `off`.
|
||||||
- `SWWAF_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A
|
- `SWW_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A
|
||||||
whole number above zero, or `off`.
|
whole number above zero, or `off`.
|
||||||
|
|
||||||
## Anomaly thresholds
|
## Anomaly thresholds
|
||||||
@@ -273,25 +280,25 @@ Each scope has four thresholds: the most requests and the most bytes counted in
|
|||||||
a minute and in an hour. Requests are counts, bytes are sizes. Each is `off` by
|
a minute and in an hour. Requests are counts, bytes are sizes. Each is `off` by
|
||||||
default. The four thresholds per client are:
|
default. The four thresholds per client are:
|
||||||
|
|
||||||
- `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
|
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
|
||||||
- `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
|
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
|
||||||
- `SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
|
- `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
|
||||||
- `SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR`.
|
- `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR`.
|
||||||
|
|
||||||
The other scopes' names have another prefix before the same four endings, such
|
The other scopes' names have another prefix before the same four endings, such
|
||||||
as `REQUESTS_PER_MINUTE`:
|
as `REQUESTS_PER_MINUTE`:
|
||||||
|
|
||||||
- `SWWAF_ANOMALY_NET_...`: per netblock around a client. Two settings give the
|
- `SWW_ANOMALY_NET_...`: per netblock around a client. Two settings give the
|
||||||
netblock's length:
|
netblock's length:
|
||||||
- `SWWAF_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
|
- `SWW_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
|
||||||
client;
|
client;
|
||||||
- `SWWAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
|
- `SWW_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
|
||||||
client.
|
client.
|
||||||
- `SWWAF_ANOMALY_ASN_...`: per AS number.
|
- `SWW_ANOMALY_ASN_...`: per AS number.
|
||||||
- `SWWAF_ANOMALY_TOTAL_...`: the whole service.
|
- `SWW_ANOMALY_TOTAL_...`: the whole service.
|
||||||
- `SWWAF_WATCH_...`: each netblock in `SWWAF_WATCH_NETS`, as a whole.
|
- `SWW_WATCH_...`: each netblock in `SWW_WATCH_NETS`, as a whole.
|
||||||
`SWWAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such
|
`SWW_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such as
|
||||||
as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
|
`office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
|
||||||
name, or with a bad netblock, is refused. So is a name used twice.
|
name, or with a bad netblock, is refused. So is a name used twice.
|
||||||
|
|
||||||
## Settings given as files
|
## Settings given as files
|
||||||
@@ -310,9 +317,9 @@ Then mount its directory into the container read-only, and name the file:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run \
|
docker run \
|
||||||
-v /srv/app/tokens:/etc/smallwebwaf/tokens:ro \
|
-v /srv/app/tokens:/etc/sww/tokens:ro \
|
||||||
-e SMALLWEBWAF_COMMAND=servewaf \
|
-e SWW_COMMAND=discriminator \
|
||||||
-e SWWAF_METRICS_TOKEN_FILE=/etc/smallwebwaf/tokens/metrics \
|
-e SWW_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -325,41 +332,42 @@ A few limits are fixed rather than settings:
|
|||||||
- at most 1,000 DNS blocklist queries are under way at once;
|
- at most 1,000 DNS blocklist queries are under way at once;
|
||||||
- at most 20,000 anomaly counters are kept;
|
- at most 20,000 anomaly counters are kept;
|
||||||
- each alert destination's queue holds at most 1,000 alerts;
|
- each alert destination's queue holds at most 1,000 alerts;
|
||||||
- `servewaf` holds at most 10,000 answered requests awaiting their reports;
|
- `discriminator` holds at most 10,000 answered requests awaiting their reports;
|
||||||
- `servewaf` refuses a request with more than 50 cookies or more than 100 header
|
- `discriminator` refuses a request with more than 50 cookies or more than 100
|
||||||
fields (see "Answering in time" under "`servewaf`" in
|
header fields (see "Answering in time" under "`discriminator`" in
|
||||||
[`SPEC.md`](../SPEC.md));
|
[`SPEC.md`](../SPEC.md));
|
||||||
- `rproxy` takes a request line and headers of at most 32 KiB, and closes a
|
- `rproxy` takes a request line and headers of at most 32 KiB, and closes a
|
||||||
kept-open connection idle for 120 seconds;
|
kept-open connection idle for 120 seconds;
|
||||||
- `rproxy` reads at most 1 MiB of the body of `servewaf`'s answer, and answers
|
- `rproxy` reads at most 1 MiB of the body of `discriminator`'s answer, and
|
||||||
`502` to a longer one.
|
answers `502` to a longer one.
|
||||||
|
|
||||||
## `servewaf`'s answer and `rproxy`'s wait
|
## `discriminator`'s answer and `rproxy`'s wait
|
||||||
|
|
||||||
`SWWAF_LOOKUP_TIMEOUT` is the longest a question waits on an outside source.
|
`SWW_LOOKUP_TIMEOUT` is the longest a question waits on an outside source. Keep
|
||||||
Keep it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question
|
it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question waits
|
||||||
waits for `SWWAF_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are
|
for `SWW_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are asked
|
||||||
asked in the background (see "Answering in time" under "`servewaf`" in
|
in the background (see "Answering in time" under "`discriminator`" in
|
||||||
[`SPEC.md`](../SPEC.md)).
|
[`SPEC.md`](../SPEC.md)).
|
||||||
|
|
||||||
## `rproxy`
|
## `rproxy`
|
||||||
|
|
||||||
`smallwebwaf rproxy` reads the settings below and no others. They are read once
|
`sww rproxy` reads the settings below and no others. They are read once at
|
||||||
at start, and an invalid value stops the start. None can be given as a file,
|
start, and an invalid value stops the start. None can be given as a file, since
|
||||||
since `rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says
|
`rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what
|
||||||
what they do.
|
they do.
|
||||||
|
|
||||||
A time here is a whole number of milliseconds above zero, such as `2000`.
|
A time here is a whole number of milliseconds above zero, such as `2000`.
|
||||||
|
|
||||||
- `SWRP_WAF_BACKEND` (required, no default): `servewaf`'s URL. An `http` or
|
- `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`
|
`https` URL with a host and an optional port, and nothing more. `rproxy`
|
||||||
speaks HTTP/1.1 to it.
|
speaks HTTP/1.1 to it.
|
||||||
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `servewaf`'s
|
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for
|
||||||
answer, to a question and to a report. A time; never `off`.
|
`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
|
- `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request when
|
||||||
`servewaf` cannot be connected to, does not answer at all, or does not answer
|
`discriminator` cannot be connected to, does not answer at all, or does not
|
||||||
within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny` answers
|
answer within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny`
|
||||||
`503`. It does not apply to an answer `servewaf` gives, such as an error.
|
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
|
- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or
|
||||||
`off`.
|
`off`.
|
||||||
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or
|
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or
|
||||||
@@ -383,22 +391,22 @@ A time here is a whole number of milliseconds above zero, such as `2000`.
|
|||||||
|
|
||||||
## The image's command
|
## The image's command
|
||||||
|
|
||||||
`SMALLWEBWAF_COMMAND` (default `rproxy`) chooses what the image runs and what
|
`SWW_COMMAND` (default `rproxy`) chooses what the image runs and what its health
|
||||||
its health check checks: `rproxy` or `servewaf` (see "Deployment" in
|
check checks: `rproxy` or `discriminator` (see "Deployment" in
|
||||||
[`SPEC.md`](../SPEC.md)). It cannot be given as a file.
|
[`SPEC.md`](../SPEC.md)). It cannot be given as a file.
|
||||||
|
|
||||||
## Where earlier settings went
|
## Where earlier settings went
|
||||||
|
|
||||||
Nothing reads these names any more:
|
Nothing reads these names any more:
|
||||||
|
|
||||||
- `SWWAF_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`.
|
- `SWW_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`.
|
||||||
- `SWWAF_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`.
|
- `SWW_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`.
|
||||||
- `SWWAF_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`.
|
- `SWW_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`.
|
||||||
- `SWWAF_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`.
|
- `SWW_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`.
|
||||||
- `SWWAF_CLIENT_REQUEST_TIMEOUT` and `SWWAF_UPSTREAM_REQUEST_TIMEOUT` are
|
- `SWW_CLIENT_REQUEST_TIMEOUT` and `SWW_UPSTREAM_REQUEST_TIMEOUT` are
|
||||||
`SWRP_MAX_REQUEST_TIME_MS`, in milliseconds.
|
`SWRP_MAX_REQUEST_TIME_MS`, in milliseconds.
|
||||||
- `SWWAF_CLIENT_RESPONSE_TIMEOUT` and `SWWAF_UPSTREAM_RESPONSE_TIMEOUT` are
|
- `SWW_CLIENT_RESPONSE_TIMEOUT` and `SWW_UPSTREAM_RESPONSE_TIMEOUT` are
|
||||||
`SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds.
|
`SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds.
|
||||||
- `SWWAF_CLIENT_IDLE_TIMEOUT` and `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` have
|
- `SWW_CLIENT_IDLE_TIMEOUT` and `SWW_CLIENT_REQUEST_HEADER_MAX_BYTES` have no
|
||||||
no setting. `rproxy` closes a kept-open connection idle for 120 seconds, and
|
setting. `rproxy` closes a kept-open connection idle for 120 seconds, and
|
||||||
takes a request line and headers of at most 32 KiB.
|
takes a request line and headers of at most 32 KiB.
|
||||||
+7
-7
@@ -1,13 +1,13 @@
|
|||||||
# State files
|
# State files
|
||||||
|
|
||||||
The five JSON files in `SWWAF_STATE_DIR`, and what each holds. "Persistent
|
The five JSON files in `SWW_STATE_DIR`, and what each holds. "Persistent state"
|
||||||
state" in [`SPEC.md`](../SPEC.md) says how they are written, read and edited.
|
in [`SPEC.md`](../SPEC.md) says how they are written, read and edited. Each has
|
||||||
Each has a top-level `version` of 1.
|
a top-level `version` of 1.
|
||||||
|
|
||||||
## `bans.json`
|
## `bans.json`
|
||||||
|
|
||||||
Active and past bans: every ban an admin made, and up to `SWWAF_MAX_BANS` of
|
Active and past bans: every ban an admin made, and up to `SWW_MAX_BANS` of those
|
||||||
those `servewaf` made. Per entry:
|
`discriminator` made. Per entry:
|
||||||
|
|
||||||
- `netblock`, `start` and `expires` (`null` for a permanent ban);
|
- `netblock`, `start` and `expires` (`null` for a permanent ban);
|
||||||
- `cause`: `attack`, `limit`, `admin` or `crowdsec`;
|
- `cause`: `attack`, `limit`, `admin` or `crowdsec`;
|
||||||
@@ -93,10 +93,10 @@ GeoJS answers, one per line:
|
|||||||
## Sizes
|
## Sizes
|
||||||
|
|
||||||
- `clients.json`: about 1 KiB per client, about 20 MiB at the default
|
- `clients.json`: about 1 KiB per client, about 20 MiB at the default
|
||||||
`SWWAF_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of
|
`SWW_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of
|
||||||
disk writes a day.
|
disk writes a day.
|
||||||
- `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at
|
- `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at
|
||||||
the default `SWWAF_MAX_BANS`. Written every 15 minutes, a full file comes to
|
the default `SWW_MAX_BANS`. Written every 15 minutes, a full file comes to
|
||||||
about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB
|
about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB
|
||||||
more.
|
more.
|
||||||
- `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when
|
- `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when
|
||||||
|
|||||||
+36
-36
@@ -1,12 +1,12 @@
|
|||||||
// Package alerts sends alerts on bans, on traffic over an anomaly
|
// Package alerts sends alerts on bans, on traffic over an anomaly
|
||||||
// threshold, on a source that fails and on a file with an error to each
|
// threshold, on a source that fails and on a file with an error to each
|
||||||
// destination set: to the webhook
|
// destination set: to the webhook
|
||||||
// SWWAF_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
|
// SWW_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
|
||||||
// object" in docs/alerts.md describes, to the Slack incoming
|
// object" in docs/alerts.md describes, to the Slack incoming
|
||||||
// webhook SWWAF_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
|
// webhook SWW_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
|
||||||
// ntfy topic SWWAF_ALERT_NTFY_URL names. A repeat within
|
// ntfy topic SWW_ALERT_NTFY_URL names. A repeat within
|
||||||
// SWWAF_ALERT_COOLDOWN is held back, and so is an alert past
|
// SWW_ALERT_COOLDOWN is held back, and so is an alert past
|
||||||
// SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
|
// SWW_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
|
||||||
// bounded queue of each destination's own, so that a destination that is
|
// bounded queue of each destination's own, so that a destination that is
|
||||||
// slow or unreachable holds up neither the others nor any request. The
|
// slow or unreachable holds up neither the others nor any request. The
|
||||||
// state is written to alerts.json and read from it by the state package.
|
// state is written to alerts.json and read from it by the state package.
|
||||||
@@ -34,41 +34,41 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The events an alert is for, as SWWAF_ALERT_EVENTS names them.
|
// The events an alert is for, as SWW_ALERT_EVENTS names them.
|
||||||
const (
|
const (
|
||||||
// EventBan is a ban smallwebwaf made.
|
// EventBan is a ban sww made.
|
||||||
EventBan = "ban"
|
EventBan = "ban"
|
||||||
// EventPermanentBan is a permanent ban smallwebwaf made, or a ban it
|
// EventPermanentBan is a permanent ban sww made, or a ban it
|
||||||
// made permanent.
|
// made permanent.
|
||||||
EventPermanentBan = "permanent_ban"
|
EventPermanentBan = "permanent_ban"
|
||||||
// EventAnomaly is a count of requests or bytes over an anomaly
|
// EventAnomaly is a count of requests or bytes over an anomaly
|
||||||
// threshold.
|
// threshold.
|
||||||
EventAnomaly = "anomaly"
|
EventAnomaly = "anomaly"
|
||||||
// EventWAFBlock is a request the Core Rule Set scored at or over
|
// EventWAFBlock is a request the Core Rule Set scored at or over
|
||||||
// SWWAF_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
|
// SWW_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
|
||||||
// detect mode.
|
// detect mode.
|
||||||
EventWAFBlock = "waf_block"
|
EventWAFBlock = "waf_block"
|
||||||
// EventReputationHit is a request whose client a blocklist, the
|
// EventReputationHit is a request whose client a blocklist, the
|
||||||
// CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score
|
// CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score
|
||||||
// is a hit.
|
// is a hit.
|
||||||
EventReputationHit = "reputation_hit"
|
EventReputationHit = "reputation_hit"
|
||||||
// EventSourceFailure is GeoJS failing or refusing smallwebwaf, a fetch
|
// EventSourceFailure is GeoJS failing or refusing sww, a fetch
|
||||||
// of a list failing, a query to a DNSBL zone or a check with AbuseIPDB
|
// of a list failing, a query to a DNSBL zone or a check with AbuseIPDB
|
||||||
// failing or refused, or the day's AbuseIPDB checks used up.
|
// failing or refused, or the day's AbuseIPDB checks used up.
|
||||||
EventSourceFailure = "source_failure"
|
EventSourceFailure = "source_failure"
|
||||||
// EventFileError is a rule file or state file edited while smallwebwaf
|
// EventFileError is a rule file or state file edited while sww
|
||||||
// runs that does not parse, a replacement of the lookup database that
|
// runs that does not parse, a replacement of the lookup database that
|
||||||
// cannot be read, or a state file that cannot be written.
|
// cannot be read, or a state file that cannot be written.
|
||||||
EventFileError = "file_error"
|
EventFileError = "file_error"
|
||||||
// EventSummary is the summary sent as an hour ends: of the alerts held
|
// EventSummary is the summary sent as an hour ends: of the alerts held
|
||||||
// back in it past SWWAF_ALERT_MAX_PER_HOUR, and of the repeats held
|
// back in it past SWW_ALERT_MAX_PER_HOUR, and of the repeats held
|
||||||
// back by the cooldowns dropped as it ends, which no alert let through
|
// back by the cooldowns dropped as it ends, which no alert let through
|
||||||
// has given. It is sent with SWWAF_ALERT_MAX_PER_HOUR off too, for
|
// has given. It is sent with SWW_ALERT_MAX_PER_HOUR off too, for
|
||||||
// those repeats. SWWAF_ALERT_EVENTS does not name it.
|
// those repeats. SWW_ALERT_EVENTS does not name it.
|
||||||
EventSummary = "summary"
|
EventSummary = "summary"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Events returns every event SWWAF_ALERT_EVENTS can name, which is its
|
// Events returns every event SWW_ALERT_EVENTS can name, which is its
|
||||||
// default.
|
// default.
|
||||||
func Events() []string {
|
func Events() []string {
|
||||||
return []string{
|
return []string{
|
||||||
@@ -80,12 +80,12 @@ func Events() []string {
|
|||||||
// The destinations alerts are sent to, as the metrics and alerts.json
|
// The destinations alerts are sent to, as the metrics and alerts.json
|
||||||
// name them.
|
// name them.
|
||||||
const (
|
const (
|
||||||
// DestinationWebhook is the webhook SWWAF_ALERT_WEBHOOK_URL names.
|
// DestinationWebhook is the webhook SWW_ALERT_WEBHOOK_URL names.
|
||||||
DestinationWebhook = "webhook"
|
DestinationWebhook = "webhook"
|
||||||
// DestinationSlack is the Slack incoming webhook
|
// DestinationSlack is the Slack incoming webhook
|
||||||
// SWWAF_ALERT_SLACK_WEBHOOK_URL names.
|
// SWW_ALERT_SLACK_WEBHOOK_URL names.
|
||||||
DestinationSlack = "slack"
|
DestinationSlack = "slack"
|
||||||
// DestinationNtfy is the ntfy topic SWWAF_ALERT_NTFY_URL names.
|
// DestinationNtfy is the ntfy topic SWW_ALERT_NTFY_URL names.
|
||||||
DestinationNtfy = "ntfy"
|
DestinationNtfy = "ntfy"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,26 +121,26 @@ var (
|
|||||||
// NtfyURL set, no alert is sent.
|
// NtfyURL set, no alert is sent.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// WebhookURL is where each alert is posted as JSON
|
// WebhookURL is where each alert is posted as JSON
|
||||||
// (SWWAF_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
|
// (SWW_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
|
||||||
// are sent with each (SWWAF_ALERT_WEBHOOK_HEADERS).
|
// are sent with each (SWW_ALERT_WEBHOOK_HEADERS).
|
||||||
WebhookURL *url.URL
|
WebhookURL *url.URL
|
||||||
WebhookHeaders http.Header
|
WebhookHeaders http.Header
|
||||||
// SlackURL is the Slack incoming webhook each alert is posted to as a
|
// SlackURL is the Slack incoming webhook each alert is posted to as a
|
||||||
// message (SWWAF_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
|
// message (SWW_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
|
||||||
SlackURL *url.URL
|
SlackURL *url.URL
|
||||||
// NtfyURL is the ntfy topic each alert is published to
|
// NtfyURL is the ntfy topic each alert is published to
|
||||||
// (SWWAF_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
|
// (SWW_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
|
||||||
// empty, is sent with each as a bearer token (SWWAF_ALERT_NTFY_TOKEN).
|
// empty, is sent with each as a bearer token (SWW_ALERT_NTFY_TOKEN).
|
||||||
NtfyURL *url.URL
|
NtfyURL *url.URL
|
||||||
NtfyToken string
|
NtfyToken string
|
||||||
// Events are the events alerts are sent for (SWWAF_ALERT_EVENTS).
|
// Events are the events alerts are sent for (SWW_ALERT_EVENTS).
|
||||||
Events []string
|
Events []string
|
||||||
// Cooldown is how long a repeat of an alert is held back
|
// Cooldown is how long a repeat of an alert is held back
|
||||||
// (SWWAF_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
|
// (SWW_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
|
||||||
// sent in an hour (SWWAF_ALERT_MAX_PER_HOUR), 0 for no limit.
|
// sent in an hour (SWW_ALERT_MAX_PER_HOUR), 0 for no limit.
|
||||||
Cooldown time.Duration
|
Cooldown time.Duration
|
||||||
MaxPerHour int
|
MaxPerHour int
|
||||||
// Instance is SWWAF_INSTANCE_NAME, which every alert gives.
|
// Instance is SWW_INSTANCE_NAME, which every alert gives.
|
||||||
Instance string
|
Instance string
|
||||||
// Now tells the time of an alert, normally time.Now in UTC.
|
// Now tells the time of an alert, normally time.Now in UTC.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
@@ -151,7 +151,7 @@ type Params struct {
|
|||||||
// Alert is one alert, as the webhook is sent it and alerts.json holds it,
|
// 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
|
// 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
|
// 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 servewaf serves
|
// the site of the request that raised the alert, while one discriminator serves
|
||||||
// many sites, and "" otherwise.
|
// many sites, and "" otherwise.
|
||||||
//
|
//
|
||||||
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
|
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
|
||||||
@@ -301,17 +301,17 @@ func New(params Params) *Queue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if params.WebhookURL != nil {
|
if params.WebhookURL != nil {
|
||||||
q.addDestination(DestinationWebhook, "SWWAF_ALERT_WEBHOOK_URL",
|
q.addDestination(DestinationWebhook, "SWW_ALERT_WEBHOOK_URL",
|
||||||
params.WebhookURL, q.webhookMessage)
|
params.WebhookURL, q.webhookMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
if params.SlackURL != nil {
|
if params.SlackURL != nil {
|
||||||
q.addDestination(DestinationSlack, "SWWAF_ALERT_SLACK_WEBHOOK_URL",
|
q.addDestination(DestinationSlack, "SWW_ALERT_SLACK_WEBHOOK_URL",
|
||||||
params.SlackURL, slackMessage)
|
params.SlackURL, slackMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
if params.NtfyURL != nil {
|
if params.NtfyURL != nil {
|
||||||
q.addDestination(DestinationNtfy, "SWWAF_ALERT_NTFY_URL",
|
q.addDestination(DestinationNtfy, "SWW_ALERT_NTFY_URL",
|
||||||
params.NtfyURL, q.ntfyMessage)
|
params.NtfyURL, q.ntfyMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +319,7 @@ func New(params Params) *Queue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Raise sends alert, which names its event and what is particular to it,
|
// Raise sends alert, which names its event and what is particular to it,
|
||||||
// unless no destination is set or SWWAF_ALERT_EVENTS leaves its event
|
// unless no destination is set or SWW_ALERT_EVENTS leaves its event
|
||||||
// out. It gives alert the instance and the time. An alert that repeats
|
// out. It gives alert the instance and the time. An alert that repeats
|
||||||
// the last one let through less than Cooldown before is held back and
|
// the last one let through less than Cooldown before is held back and
|
||||||
// counted. The next one let through gives that count, unless an hour of
|
// counted. The next one let through gives that count, unless an hour of
|
||||||
@@ -363,7 +363,7 @@ func (q *Queue) Raise(alert Alert) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WouldSend reports whether Raise would let an alert for event on
|
// WouldSend reports whether Raise would let an alert for event on
|
||||||
// netblock through now: a destination is set, SWWAF_ALERT_EVENTS chooses
|
// netblock through now: a destination is set, SWW_ALERT_EVENTS chooses
|
||||||
// event, no alert for event on netblock was let through less than
|
// event, no alert for event on netblock was let through less than
|
||||||
// Cooldown before, and fewer than MaxPerHour alerts have been let through
|
// Cooldown before, and fewer than MaxPerHour alerts have been let through
|
||||||
// in the hour under way. Unlike Raise, it counts nothing.
|
// in the hour under way. Unlike Raise, it counts nothing.
|
||||||
@@ -606,13 +606,13 @@ func (q *Queue) endHour(now time.Time) {
|
|||||||
|
|
||||||
if heldBack > 0 {
|
if heldBack > 0 {
|
||||||
reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
|
reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
|
||||||
"past the %d an hour SWWAF_ALERT_MAX_PER_HOUR allows", heldBack,
|
"past the %d an hour SWW_ALERT_MAX_PER_HOUR allows", heldBack,
|
||||||
q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
|
q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
|
||||||
}
|
}
|
||||||
|
|
||||||
if repeats > 0 {
|
if repeats > 0 {
|
||||||
reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+
|
reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+
|
||||||
"SWWAF_ALERT_COOLDOWN that no later alert gives", repeats))
|
"SWW_ALERT_COOLDOWN that no later alert gives", repeats))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(reasons) > 0 {
|
if len(reasons) > 0 {
|
||||||
@@ -683,7 +683,7 @@ func (d *destination) run(ctx context.Context) {
|
|||||||
|
|
||||||
d.processLog.Warn("gave up an alert "+d.setting+" refused",
|
d.processLog.Warn("gave up an alert "+d.setting+" refused",
|
||||||
"event", alert.Event, "error", err.Error())
|
"event", alert.Event, "error", err.Error())
|
||||||
case ctx.Err() == nil: // not cut off as smallwebwaf stops
|
case ctx.Err() == nil: // not cut off as sww stops
|
||||||
d.failed.Add(1)
|
d.failed.Add(1)
|
||||||
|
|
||||||
retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay),
|
retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay),
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ const (
|
|||||||
// webhookURL is where the alerts are posted, slackURL the Slack
|
// webhookURL is where the alerts are posted, slackURL the Slack
|
||||||
// incoming webhook, and ntfyURL the ntfy topic. ntfyToken is the ntfy
|
// incoming webhook, and ntfyURL the ntfy topic. ntfyToken is the ntfy
|
||||||
// token of the tests that set one.
|
// token of the tests that set one.
|
||||||
webhookURL = "https://alerts.example/smallwebwaf?team=ops"
|
webhookURL = "https://alerts.example/sww?team=ops"
|
||||||
slackURL = "https://hooks.slack.example/services/T0123/B4567/abcdef"
|
slackURL = "https://hooks.slack.example/services/T0123/B4567/abcdef"
|
||||||
ntfyURL = "https://ntfy.example/smallwebwaf-alerts"
|
ntfyURL = "https://ntfy.example/sww-alerts"
|
||||||
ntfyToken = "tk_0123456789abcdefghijklmnopq"
|
ntfyToken = "tk_0123456789abcdefghijklmnopq"
|
||||||
// instance is the instance name every alert gives.
|
// instance is the instance name every alert gives.
|
||||||
instance = "fsn1app1/blog"
|
instance = "fsn1app1/blog"
|
||||||
@@ -132,7 +132,7 @@ func TestWouldSendOnlyForTheChosenEvents(t *testing.T) {
|
|||||||
|
|
||||||
if q.WouldSend(alerts.EventBan, netblock(1)) ||
|
if q.WouldSend(alerts.EventBan, netblock(1)) ||
|
||||||
q.WouldSend(alerts.EventPermanentBan, netblock(1)) {
|
q.WouldSend(alerts.EventPermanentBan, netblock(1)) {
|
||||||
t.Error("a ban alert would be sent, though SWWAF_ALERT_EVENTS leaves it out")
|
t.Error("a ban alert would be sent, though SWW_ALERT_EVENTS leaves it out")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) {
|
if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) {
|
||||||
@@ -184,8 +184,8 @@ func TestWouldSendWithOnlySlackOrOnlyNtfySet(t *testing.T) {
|
|||||||
onlyNtfy.NtfyURL = parseURL(ntfyURL)
|
onlyNtfy.NtfyURL = parseURL(ntfyURL)
|
||||||
|
|
||||||
for setting, params := range map[string]alerts.Params{
|
for setting, params := range map[string]alerts.Params{
|
||||||
"SWWAF_ALERT_SLACK_WEBHOOK_URL": onlySlack,
|
"SWW_ALERT_SLACK_WEBHOOK_URL": onlySlack,
|
||||||
"SWWAF_ALERT_NTFY_URL": onlyNtfy,
|
"SWW_ALERT_NTFY_URL": onlyNtfy,
|
||||||
} {
|
} {
|
||||||
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
|
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
|
||||||
t.Errorf("a ban alert would not be sent with only %s set", setting)
|
t.Errorf("a ban alert would not be sent with only %s set", setting)
|
||||||
@@ -359,7 +359,7 @@ func TestAlertsPastTheHourlyLimitAreRolledIntoOneSummary(t *testing.T) {
|
|||||||
"as_name": "",
|
"as_name": "",
|
||||||
"country": "",
|
"country": "",
|
||||||
"reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
"reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
||||||
"past the 2 an hour SWWAF_ALERT_MAX_PER_HOUR allows",
|
"past the 2 an hour SWW_ALERT_MAX_PER_HOUR allows",
|
||||||
"detail": map[string]any{
|
"detail": map[string]any{
|
||||||
"hour": started,
|
"hour": started,
|
||||||
"count": float64(3),
|
"count": float64(3),
|
||||||
@@ -525,7 +525,7 @@ func TestFailedRequestIsSentAgainWithBackoff(t *testing.T) {
|
|||||||
wantCounts(t, q, 0, int64(len(want)), 0, 0)
|
wantCounts(t, q, 0, int64(len(want)), 0, 0)
|
||||||
|
|
||||||
if !strings.Contains(log.String(),
|
if !strings.Contains(log.String(),
|
||||||
`"msg":"sending an alert to SWWAF_ALERT_WEBHOOK_URL failed"`) {
|
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) {
|
||||||
t.Errorf("process log %q names no failure", log.String())
|
t.Errorf("process log %q names no failure", log.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,7 +595,7 @@ func TestRefusedAlertIsGivenUpAndTheNextSent(t *testing.T) {
|
|||||||
wantCounts(t, q, 1, 3, 0, 1)
|
wantCounts(t, q, 1, 3, 0, 1)
|
||||||
|
|
||||||
if !strings.Contains(log.String(),
|
if !strings.Contains(log.String(),
|
||||||
`"msg":"gave up an alert SWWAF_ALERT_WEBHOOK_URL refused"`) {
|
`"msg":"gave up an alert SWW_ALERT_WEBHOOK_URL refused"`) {
|
||||||
t.Errorf("process log %q names no alert given up", log.String())
|
t.Errorf("process log %q names no alert given up", log.String())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -619,7 +619,7 @@ func TestFailedRequestIsLoggedWithoutTheURL(t *testing.T) {
|
|||||||
|
|
||||||
logged := log.String()
|
logged := log.String()
|
||||||
if !strings.Contains(logged,
|
if !strings.Contains(logged,
|
||||||
`"msg":"sending an alert to SWWAF_ALERT_WEBHOOK_URL failed"`) ||
|
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) ||
|
||||||
strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") {
|
strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") {
|
||||||
t.Errorf("process log %q names no failure, or names the URL", logged)
|
t.Errorf("process log %q names no failure, or names the URL", logged)
|
||||||
}
|
}
|
||||||
@@ -782,8 +782,8 @@ func TestSlackAndNtfyAreSentTheSummaryAndTheRepeatsHeldBack(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
||||||
"past the 1 an hour SWWAF_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
|
"past the 1 an hour SWW_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
|
||||||
"by SWWAF_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
|
"by SWW_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
|
||||||
|
|
||||||
wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary)
|
wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary)
|
||||||
wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary)
|
wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary)
|
||||||
@@ -901,7 +901,7 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) {
|
|||||||
|
|
||||||
logged := log.String()
|
logged := log.String()
|
||||||
if !strings.Contains(logged,
|
if !strings.Contains(logged,
|
||||||
`"msg":"sending an alert to SWWAF_ALERT_SLACK_WEBHOOK_URL failed"`) ||
|
`"msg":"sending an alert to SWW_ALERT_SLACK_WEBHOOK_URL failed"`) ||
|
||||||
strings.Contains(logged, "hooks.slack.example") ||
|
strings.Contains(logged, "hooks.slack.example") ||
|
||||||
strings.Contains(logged, "T0123") {
|
strings.Contains(logged, "T0123") {
|
||||||
t.Errorf("process log %q names no failure, or names the URL", logged)
|
t.Errorf("process log %q names no failure, or names the URL", logged)
|
||||||
@@ -1254,7 +1254,7 @@ type eventMessage struct {
|
|||||||
title, priorityAndTag, text string
|
title, priorityAndTag, text string
|
||||||
}
|
}
|
||||||
|
|
||||||
// anAlertForEachEvent returns an alert for each event SWWAF_ALERT_EVENTS
|
// anAlertForEachEvent returns an alert for each event SWW_ALERT_EVENTS
|
||||||
// names, the first in observe mode, each with its message.
|
// names, the first in observe mode, each with its message.
|
||||||
func anAlertForEachEvent() []eventMessage {
|
func anAlertForEachEvent() []eventMessage {
|
||||||
return []eventMessage{
|
return []eventMessage{
|
||||||
@@ -1320,12 +1320,12 @@ func anAlertForEachEvent() []eventMessage {
|
|||||||
Event: alerts.EventFileError,
|
Event: alerts.EventFileError,
|
||||||
Reason: "a rule file has an error, and the rules stay as they were",
|
Reason: "a rule file has an error, and the rules stay as they were",
|
||||||
Detail: map[string]any{
|
Detail: map[string]any{
|
||||||
"file": "/etc/smallwebwaf/rules.d/50-app.rules", "error": "line 2: no action",
|
"file": "/etc/sww/rules.d/50-app.rules", "error": "line 2: no action",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"fsn1app1/blog: file_error", "high warning",
|
"fsn1app1/blog: file_error", "high warning",
|
||||||
"a rule file has an error, and the rules stay as they were\n" +
|
"a rule file has an error, and the rules stay as they were\n" +
|
||||||
"file: /etc/smallwebwaf/rules.d/50-app.rules\nerror: line 2: no action",
|
"file: /etc/sww/rules.d/50-app.rules\nerror: line 2: no action",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-12
@@ -28,16 +28,16 @@ const maxCounters = 20000
|
|||||||
// alerts name them.
|
// alerts name them.
|
||||||
const (
|
const (
|
||||||
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
|
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
|
||||||
// SWWAF_IPV6_GROUP_PREFIX.
|
// SWW_IPV6_GROUP_PREFIX.
|
||||||
ScopeClient = "client"
|
ScopeClient = "client"
|
||||||
// ScopeNet is the netblock around a client, SWWAF_ANOMALY_NET_V4_PREFIX
|
// ScopeNet is the netblock around a client, SWW_ANOMALY_NET_V4_PREFIX
|
||||||
// or SWWAF_ANOMALY_NET_V6_PREFIX long.
|
// or SWW_ANOMALY_NET_V6_PREFIX long.
|
||||||
ScopeNet = "net"
|
ScopeNet = "net"
|
||||||
// ScopeASN is an AS number.
|
// ScopeASN is an AS number.
|
||||||
ScopeASN = "asn"
|
ScopeASN = "asn"
|
||||||
// ScopeTotal is the whole service.
|
// ScopeTotal is the whole service.
|
||||||
ScopeTotal = "total"
|
ScopeTotal = "total"
|
||||||
// ScopeWatch is a named netblock of SWWAF_WATCH_NETS.
|
// ScopeWatch is a named netblock of SWW_WATCH_NETS.
|
||||||
ScopeWatch = "watch"
|
ScopeWatch = "watch"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ type Thresholds struct {
|
|||||||
BytesPerHour int64
|
BytesPerHour int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NamedNetblock is a netblock SWWAF_WATCH_NETS names.
|
// NamedNetblock is a netblock SWW_WATCH_NETS names.
|
||||||
type NamedNetblock struct {
|
type NamedNetblock struct {
|
||||||
Name string
|
Name string
|
||||||
Netblock netip.Prefix
|
Netblock netip.Prefix
|
||||||
@@ -70,14 +70,14 @@ type NamedNetblock struct {
|
|||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// The thresholds of each scope: SWWAF_ANOMALY_CLIENT_*,
|
// The thresholds of each scope: SWW_ANOMALY_CLIENT_*,
|
||||||
// SWWAF_ANOMALY_NET_*, SWWAF_ANOMALY_ASN_*, SWWAF_ANOMALY_TOTAL_* and
|
// SWW_ANOMALY_NET_*, SWW_ANOMALY_ASN_*, SWW_ANOMALY_TOTAL_* and
|
||||||
// SWWAF_WATCH_*.
|
// SWW_WATCH_*.
|
||||||
Client, Net, ASN, Total, Watch Thresholds
|
Client, Net, ASN, Total, Watch Thresholds
|
||||||
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
|
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
|
||||||
// client (SWWAF_ANOMALY_NET_V4_PREFIX and SWWAF_ANOMALY_NET_V6_PREFIX).
|
// client (SWW_ANOMALY_NET_V4_PREFIX and SWW_ANOMALY_NET_V6_PREFIX).
|
||||||
NetV4Prefix, NetV6Prefix int
|
NetV4Prefix, NetV6Prefix int
|
||||||
// NamedNetblocks are SWWAF_WATCH_NETS.
|
// NamedNetblocks are SWW_WATCH_NETS.
|
||||||
NamedNetblocks []NamedNetblock
|
NamedNetblocks []NamedNetblock
|
||||||
// Alerts receive the anomaly alerts.
|
// Alerts receive the anomaly alerts.
|
||||||
Alerts *alerts.Queue
|
Alerts *alerts.Queue
|
||||||
@@ -107,13 +107,13 @@ type Request struct {
|
|||||||
Host string
|
Host string
|
||||||
// Client is the client's address, and ClientGroup the client it is
|
// Client is the client's address, and ClientGroup the client it is
|
||||||
// counted as: its IPv4 address, or the IPv6 netblock of
|
// counted as: its IPv4 address, or the IPv6 netblock of
|
||||||
// SWWAF_IPV6_GROUP_PREFIX its address is in.
|
// SWW_IPV6_GROUP_PREFIX its address is in.
|
||||||
Client netip.Addr
|
Client netip.Addr
|
||||||
ClientGroup netip.Prefix
|
ClientGroup netip.Prefix
|
||||||
// ASN, ASName and Country are the client's as looked up, each "" when
|
// ASN, ASName and Country are the client's as looked up, each "" when
|
||||||
// unknown.
|
// unknown.
|
||||||
ASN, ASName, Country string
|
ASN, ASName, Country string
|
||||||
// Bytes are the request's bytes, as SWWAF_BYTES_COUNT counts them.
|
// Bytes are the request's bytes, as SWW_BYTES_COUNT counts them.
|
||||||
Bytes int64
|
Bytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
|
|||||||
third := netip.MustParsePrefix("203.0.113.3/32")
|
third := netip.MustParsePrefix("203.0.113.3/32")
|
||||||
|
|
||||||
// Seen longest ago, a netblock with two of an admin's bans alone, and
|
// Seen longest ago, a netblock with two of an admin's bans alone, and
|
||||||
// then one with an admin's ban before a ban smallwebwaf made: the one
|
// then one with an admin's ban before a ban sww made: the one
|
||||||
// ban counted toward MaxBans.
|
// ban counted toward MaxBans.
|
||||||
ledger.Load([]bans.Ban{
|
ledger.Load([]bans.Ban{
|
||||||
{Netblock: adminsOnly, Start: midnight().Add(-3 * time.Hour), Cause: bans.CauseAdmin},
|
{Netblock: adminsOnly, Start: midnight().Add(-3 * time.Hour), Cause: bans.CauseAdmin},
|
||||||
@@ -48,7 +48,7 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
|
|||||||
})
|
})
|
||||||
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 2})
|
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 2})
|
||||||
|
|
||||||
// A new ban drops the ban smallwebwaf made, and only that one.
|
// A new ban drops the ban sww made, and only that one.
|
||||||
ledger.BanForLimit(second, midnight(), bans.Notes{})
|
ledger.BanForLimit(second, midnight(), bans.Notes{})
|
||||||
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1})
|
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1})
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReasonOfTheBansSmallwebwafMakes(t *testing.T) {
|
func TestReasonOfTheBansSwwMakes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultRules())
|
||||||
@@ -226,7 +226,7 @@ func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
|
|||||||
t.Fatalf("%d bans made by an admin after the start's, want none", got)
|
t.Fatalf("%d bans made by an admin after the start's, want none", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The admin keeps the ban smallwebwaf made, keeps the one read at the
|
// The admin keeps the ban sww made, keeps the one read at the
|
||||||
// start, and adds one without a cause: that one alone is made.
|
// start, and adds one without a cause: that one alone is made.
|
||||||
kept := made
|
kept := made
|
||||||
kept.Cause = bans.CauseAdmin
|
kept.Cause = bans.CauseAdmin
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package bans is the ban ledger: the bans smallwebwaf makes on the
|
// Package bans is the ban ledger: the bans sww makes on the
|
||||||
// netblocks of clients that break a rate limit, a byte limit or the error
|
// netblocks of clients that break a rate limit, a byte limit or the error
|
||||||
// burst, show a clear sign of attack or are listed by the CrowdSec
|
// burst, show a clear sign of attack or are listed by the CrowdSec
|
||||||
// decision list, and
|
// decision list, and
|
||||||
@@ -21,14 +21,14 @@ import (
|
|||||||
|
|
||||||
// The causes of bans.
|
// The causes of bans.
|
||||||
const (
|
const (
|
||||||
// CauseLimit is a ban smallwebwaf made for a broken limit.
|
// CauseLimit is a ban sww made for a broken limit.
|
||||||
CauseLimit = "limit"
|
CauseLimit = "limit"
|
||||||
// CauseAttack is a ban smallwebwaf made for a clear sign of attack.
|
// CauseAttack is a ban sww made for a clear sign of attack.
|
||||||
CauseAttack = "attack"
|
CauseAttack = "attack"
|
||||||
// CauseAdmin is a ban an admin made, or one smallwebwaf made that an
|
// CauseAdmin is a ban an admin made, or one sww made that an
|
||||||
// admin keeps. It is never dropped.
|
// admin keeps. It is never dropped.
|
||||||
CauseAdmin = "admin"
|
CauseAdmin = "admin"
|
||||||
// CauseCrowdSec is a ban smallwebwaf made for a client the CrowdSec
|
// CauseCrowdSec is a ban sww made for a client the CrowdSec
|
||||||
// decision list lists. It ends when CrowdSec's decision does.
|
// decision list lists. It ends when CrowdSec's decision does.
|
||||||
CauseCrowdSec = "crowdsec"
|
CauseCrowdSec = "crowdsec"
|
||||||
)
|
)
|
||||||
@@ -70,7 +70,7 @@ type Ban struct {
|
|||||||
Expires time.Time
|
Expires time.Time
|
||||||
// Cause is CauseLimit, CauseAttack, CauseAdmin or CauseCrowdSec.
|
// Cause is CauseLimit, CauseAttack, CauseAdmin or CauseCrowdSec.
|
||||||
Cause string
|
Cause string
|
||||||
// Reason is a short text: for a ban smallwebwaf made, the limit broken,
|
// Reason is a short text: for a ban sww made, the limit broken,
|
||||||
// the rule that matched or the scenario of CrowdSec's decision; for an
|
// the rule that matched or the scenario of CrowdSec's decision; for an
|
||||||
// admin's, what the admin wrote.
|
// admin's, what the admin wrote.
|
||||||
Reason string
|
Reason string
|
||||||
@@ -123,7 +123,7 @@ type Notes struct {
|
|||||||
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
|
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
|
||||||
// RuleID and Target are, for a ban for a clear sign of attack, the id
|
// RuleID and Target are, for a ban for a clear sign of attack, the id
|
||||||
// of the rule file rule that matched, and its target; TrapPath is, for
|
// of the rule file rule that matched, and its target; TrapPath is, for
|
||||||
// one for a request for a path in SWWAF_TRAP_PATHS, that path as the
|
// one for a request for a path in SWW_TRAP_PATHS, that path as the
|
||||||
// client sent it.
|
// client sent it.
|
||||||
RuleID string `json:"rule_id,omitempty"`
|
RuleID string `json:"rule_id,omitempty"`
|
||||||
Target string `json:"target,omitempty"`
|
Target string `json:"target,omitempty"`
|
||||||
@@ -199,7 +199,7 @@ type Ledger struct {
|
|||||||
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
||||||
// netblocks that have been banned. Check looks for a ban at each of
|
// netblocks that have been banned. Check looks for a ban at each of
|
||||||
// them, so that a ban read from bans.json refuses every client in its
|
// them, so that a ban read from bans.json refuses every client in its
|
||||||
// netblock even when it was made with another SWWAF_BAN_SCOPE_V4_PREFIX,
|
// netblock even when it was made with another SWW_BAN_SCOPE_V4_PREFIX,
|
||||||
// or another length of an IPv6 client's netblock.
|
// or another length of an IPv6 client's netblock.
|
||||||
v4Lengths, v6Lengths []int
|
v4Lengths, v6Lengths []int
|
||||||
}
|
}
|
||||||
@@ -581,7 +581,7 @@ func (l *Ledger) Load(bans []Ban) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LoadEdit is Load for an admin's edit of bans.json, taken in while
|
// LoadEdit is Load for an admin's edit of bans.json, taken in while
|
||||||
// smallwebwaf runs. Each ban in it whose cause is CauseAdmin, and which
|
// sww runs. Each ban in it whose cause is CauseAdmin, and which
|
||||||
// the ledger did not hold, with the same netblock and start, is one the
|
// the ledger did not hold, with the same netblock and start, is one the
|
||||||
// admin made, and is counted among the bans made.
|
// admin made, and is counted among the bans made.
|
||||||
func (l *Ledger) LoadEdit(bans []Ban) {
|
func (l *Ledger) LoadEdit(bans []Ban) {
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
|
|||||||
func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// A 9-hour ban smallwebwaf made, the third in a row, and an admin's
|
// A 9-hour ban sww made, the third in a row, and an admin's
|
||||||
// 1-hour ban added to bans.json over it, with no cause and no notes.
|
// 1-hour ban added to bans.json over it, with no cause and no notes.
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
nineHours := bans.Ban{
|
nineHours := bans.Ban{
|
||||||
|
|||||||
+262
-247
@@ -1,9 +1,9 @@
|
|||||||
// Package config reads smallwebwaf's settings, and is the one place they are
|
// Package config reads sww's settings, and is the one place they are
|
||||||
// read. Every setting is an environment variable. The settings of `smallwebwaf
|
// read. Every setting is an environment variable. The settings of `sww
|
||||||
// servewaf` start with SWWAF_, 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 `smallwebwaf rproxy` start with SWRP_. Each
|
// 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,
|
// of them but SWRP_WAF_BACKEND has a default, and none can be given as a file,
|
||||||
// since rproxy never touches disk. SMALLWEBWAF_COMMAND chooses which of the two
|
// since rproxy never touches disk. SWW_COMMAND chooses which of the two
|
||||||
// the image runs.
|
// the image runs.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
@@ -31,88 +31,88 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the settings of `smallwebwaf servewaf`. A time or size limit of
|
// Config is the settings of `sww discriminator`. A time or size limit of
|
||||||
// zero is off.
|
// zero is off.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// ListenAddr is where servewaf listens (SWWAF_LISTEN_ADDR).
|
// ListenAddr is where discriminator listens (SWW_LISTEN_ADDR).
|
||||||
ListenAddr string
|
ListenAddr string
|
||||||
// InstanceName is the name every log line and alert gives as instance,
|
// InstanceName is the name every log line and alert gives as instance,
|
||||||
// and every metric carries as its label instance (SWWAF_INSTANCE_NAME),
|
// and every metric carries as its label instance (SWW_INSTANCE_NAME),
|
||||||
// by default the host's name, which docker sets to the first 12
|
// by default the host's name, which docker sets to the first 12
|
||||||
// characters of the container's id.
|
// characters of the container's id.
|
||||||
InstanceName string
|
InstanceName string
|
||||||
// Observe is true in observe mode, when SWWAF_MODE is observe rather
|
// Observe is true in observe mode, when SWW_MODE is observe rather
|
||||||
// than enforce: a request that SWWAF_DENY_NETS, a ban, the country
|
// than enforce: a request that SWW_DENY_NETS, a ban, the country
|
||||||
// lists, a rate limit, a rule or the Core Rule Set would refuse is
|
// lists, a rate limit, a rule or the Core Rule Set would refuse is
|
||||||
// allowed instead, and no ban is made.
|
// allowed instead, and no ban is made.
|
||||||
Observe bool
|
Observe bool
|
||||||
// IPv6GroupPrefix is the length of the IPv6 netblock that is one client
|
// IPv6GroupPrefix is the length of the IPv6 netblock that is one client
|
||||||
// (SWWAF_IPV6_GROUP_PREFIX), from 32 to 128.
|
// (SWW_IPV6_GROUP_PREFIX), from 32 to 128.
|
||||||
IPv6GroupPrefix int
|
IPv6GroupPrefix int
|
||||||
// MaxTrackedClients is the most clients the table of clients holds, in
|
// MaxTrackedClients is the most clients the table of clients holds, in
|
||||||
// memory and in clients.json (SWWAF_MAX_TRACKED_CLIENTS).
|
// memory and in clients.json (SWW_MAX_TRACKED_CLIENTS).
|
||||||
MaxTrackedClients int
|
MaxTrackedClients int
|
||||||
// AllowNets are the netblocks whose clients skip every check
|
// AllowNets are the netblocks whose clients skip every check
|
||||||
// (SWWAF_ALLOW_NETS). RateLimitExemptNets are those whose clients the
|
// (SWW_ALLOW_NETS). RateLimitExemptNets are those whose clients the
|
||||||
// rate limits neither count nor refuse (SWWAF_RATE_LIMIT_EXEMPT_NETS).
|
// rate limits neither count nor refuse (SWW_RATE_LIMIT_EXEMPT_NETS).
|
||||||
// DenyNets are those whose clients are always refused
|
// DenyNets are those whose clients are always refused
|
||||||
// (SWWAF_DENY_NETS).
|
// (SWW_DENY_NETS).
|
||||||
AllowNets []netip.Prefix
|
AllowNets []netip.Prefix
|
||||||
RateLimitExemptNets []netip.Prefix
|
RateLimitExemptNets []netip.Prefix
|
||||||
DenyNets []netip.Prefix
|
DenyNets []netip.Prefix
|
||||||
// RateLimitPerMinute, RateLimitPerHour and RateLimitPerDay are the
|
// RateLimitPerMinute, RateLimitPerHour and RateLimitPerDay are the
|
||||||
// most requests a client may make in a minute, an hour and a day
|
// most requests a client may make in a minute, an hour and a day
|
||||||
// (SWWAF_RATE_LIMIT_PER_MINUTE, SWWAF_RATE_LIMIT_PER_HOUR and
|
// (SWW_RATE_LIMIT_PER_MINUTE, SWW_RATE_LIMIT_PER_HOUR and
|
||||||
// SWWAF_RATE_LIMIT_PER_DAY).
|
// SWW_RATE_LIMIT_PER_DAY).
|
||||||
RateLimitPerMinute int64
|
RateLimitPerMinute int64
|
||||||
RateLimitPerHour int64
|
RateLimitPerHour int64
|
||||||
RateLimitPerDay int64
|
RateLimitPerDay int64
|
||||||
// RateLimitExemptPaths are the path prefixes whose requests the rate
|
// RateLimitExemptPaths are the path prefixes whose requests the rate
|
||||||
// limits neither count nor refuse (SWWAF_RATE_LIMIT_EXEMPT_PATHS).
|
// limits neither count nor refuse (SWW_RATE_LIMIT_EXEMPT_PATHS).
|
||||||
// Each starts with /.
|
// Each starts with /.
|
||||||
RateLimitExemptPaths []string
|
RateLimitExemptPaths []string
|
||||||
// BytesLimitPerMinute, BytesLimitPerHour and BytesLimitPerDay are the
|
// BytesLimitPerMinute, BytesLimitPerHour and BytesLimitPerDay are the
|
||||||
// most bytes a client's requests may carry in a minute, an hour and a
|
// most bytes a client's requests may carry in a minute, an hour and a
|
||||||
// day (SWWAF_BYTES_LIMIT_PER_MINUTE, SWWAF_BYTES_LIMIT_PER_HOUR and
|
// day (SWW_BYTES_LIMIT_PER_MINUTE, SWW_BYTES_LIMIT_PER_HOUR and
|
||||||
// SWWAF_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count
|
// SWW_BYTES_LIMIT_PER_DAY). BytesCount is which body bytes count
|
||||||
// toward them (SWWAF_BYTES_COUNT): response, request or both.
|
// toward them (SWW_BYTES_COUNT): response, request or both.
|
||||||
BytesLimitPerMinute int64
|
BytesLimitPerMinute int64
|
||||||
BytesLimitPerHour int64
|
BytesLimitPerHour int64
|
||||||
BytesLimitPerDay int64
|
BytesLimitPerDay int64
|
||||||
BytesCount string
|
BytesCount string
|
||||||
// LookupSource is where each client's AS number and country are
|
// LookupSource is where each client's AS number and country are
|
||||||
// looked up (SWWAF_LOOKUP_SOURCE): geojs, file, or off for nowhere.
|
// looked up (SWW_LOOKUP_SOURCE): geojs, file, or off for nowhere.
|
||||||
// LookupDBPath is the lookup database, the IPinfo Lite file looked up
|
// LookupDBPath is the lookup database, the IPinfo Lite file looked up
|
||||||
// in while LookupSource is file (SWWAF_LOOKUP_DB_PATH), and "" for any
|
// in while LookupSource is file (SWW_LOOKUP_DB_PATH), and "" for any
|
||||||
// other source. A request waits up to LookupTimeout for its client's
|
// other source. A request waits up to LookupTimeout for its client's
|
||||||
// first answer from GeoJS while a setting needs it
|
// first answer from GeoJS while a setting needs it
|
||||||
// (SWWAF_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true
|
// (SWW_LOOKUP_TIMEOUT), which cannot be off. AddLookupHeaders is true
|
||||||
// when the app is passed the client's AS number and country in headers
|
// when the app is passed the client's AS number and country in headers
|
||||||
// (SWWAF_ADD_LOOKUP_HEADERS).
|
// (SWW_ADD_LOOKUP_HEADERS).
|
||||||
LookupSource string
|
LookupSource string
|
||||||
LookupDBPath string
|
LookupDBPath string
|
||||||
LookupTimeout time.Duration
|
LookupTimeout time.Duration
|
||||||
AddLookupHeaders bool
|
AddLookupHeaders bool
|
||||||
// DeniedCountries are the countries whose clients are refused
|
// DeniedCountries are the countries whose clients are refused
|
||||||
// (SWWAF_DENIED_COUNTRIES). ExclusivelyAllowedCountries, when not
|
// (SWW_DENIED_COUNTRIES). ExclusivelyAllowedCountries, when not
|
||||||
// empty, are the only countries whose clients are let through
|
// empty, are the only countries whose clients are let through
|
||||||
// (SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES). Both hold two-letter codes in
|
// (SWW_EXCLUSIVELY_ALLOWED_COUNTRIES). Both hold two-letter codes in
|
||||||
// capitals, as GeoJS gives them.
|
// capitals, as GeoJS gives them.
|
||||||
DeniedCountries []string
|
DeniedCountries []string
|
||||||
ExclusivelyAllowedCountries []string
|
ExclusivelyAllowedCountries []string
|
||||||
// The biased thresholds. ASNLimitPercent and CountryLimitPercent give
|
// The biased thresholds. ASNLimitPercent and CountryLimitPercent give
|
||||||
// the clients of the AS numbers and the countries they list that
|
// the clients of the AS numbers and the countries they list that
|
||||||
// percentage of every rate limit and byte limit
|
// percentage of every rate limit and byte limit
|
||||||
// (SWWAF_ASN_LIMIT_PERCENT and SWWAF_COUNTRY_LIMIT_PERCENT).
|
// (SWW_ASN_LIMIT_PERCENT and SWW_COUNTRY_LIMIT_PERCENT).
|
||||||
// ASNBytesPercent and CountryBytesPercent give those they list a
|
// ASNBytesPercent and CountryBytesPercent give those they list a
|
||||||
// percentage of the byte limits in place of that one
|
// percentage of the byte limits in place of that one
|
||||||
// (SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT). Each holds
|
// (SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT). Each holds
|
||||||
// percentages from 0 to 100, by AS number, written as AS64496, or by
|
// percentages from 0 to 100, by AS number, written as AS64496, or by
|
||||||
// country, a two-letter code in capitals, as the lookup gives them.
|
// country, a two-letter code in capitals, as the lookup gives them.
|
||||||
// UnknownLimitPercent is the percentage of every limit a client without
|
// UnknownLimitPercent is the percentage of every limit a client without
|
||||||
// a country gets (SWWAF_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
|
// a country gets (SWW_UNKNOWN_LIMIT_PERCENT). ASNLimitPercentURL is
|
||||||
// where a file of AS:percent lines is fetched from, whose percentages
|
// where a file of AS:percent lines is fetched from, whose percentages
|
||||||
// count as those of ASNLimitPercent do (SWWAF_ASN_LIMIT_PERCENT_URL), ""
|
// count as those of ASNLimitPercent do (SWW_ASN_LIMIT_PERCENT_URL), ""
|
||||||
// while it is unset.
|
// while it is unset.
|
||||||
ASNLimitPercent map[string]int64
|
ASNLimitPercent map[string]int64
|
||||||
CountryLimitPercent map[string]int64
|
CountryLimitPercent map[string]int64
|
||||||
@@ -121,30 +121,30 @@ type Config struct {
|
|||||||
UnknownLimitPercent int64
|
UnknownLimitPercent int64
|
||||||
ASNLimitPercentURL string
|
ASNLimitPercentURL string
|
||||||
// BlocklistURLs are where the blocklists are fetched from
|
// BlocklistURLs are where the blocklists are fetched from
|
||||||
// (SWWAF_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is
|
// (SWW_BLOCKLIST_URLS). Each list, and ASNLimitPercentURL's, is
|
||||||
// fetched again BlocklistRefresh after it was last fetched or tried
|
// fetched again BlocklistRefresh after it was last fetched or tried
|
||||||
// (SWWAF_BLOCKLIST_REFRESH), which is never less than an hour.
|
// (SWW_BLOCKLIST_REFRESH), which is never less than an hour.
|
||||||
// BlocklistAction is what is done with a client a blocklist lists
|
// BlocklistAction is what is done with a client a blocklist lists
|
||||||
// (SWWAF_BLOCKLIST_ACTION): deny, limit or log; for limit,
|
// (SWW_BLOCKLIST_ACTION): deny, limit or log; for limit,
|
||||||
// BlocklistLimitPercent is the percentage of every limit it gets.
|
// BlocklistLimitPercent is the percentage of every limit it gets.
|
||||||
BlocklistURLs []string
|
BlocklistURLs []string
|
||||||
BlocklistRefresh time.Duration
|
BlocklistRefresh time.Duration
|
||||||
BlocklistAction string
|
BlocklistAction string
|
||||||
BlocklistLimitPercent int64
|
BlocklistLimitPercent int64
|
||||||
// DNSBLZones are the DNSBL zones clients are asked about
|
// DNSBLZones are the DNSBL zones clients are asked about
|
||||||
// (SWWAF_DNSBL_ZONES), through DNSBLResolver (SWWAF_DNSBL_RESOLVER), or
|
// (SWW_DNSBL_ZONES), through DNSBLResolver (SWW_DNSBL_RESOLVER), or
|
||||||
// the host's resolver while that is the zero AddrPort.
|
// the host's resolver while that is the zero AddrPort.
|
||||||
// AbuseIPDBKey is the key of the AbuseIPDB account clients are checked
|
// AbuseIPDBKey is the key of the AbuseIPDB account clients are checked
|
||||||
// with (SWWAF_ABUSEIPDB_KEY), "" while it is unset and none is. A score
|
// with (SWW_ABUSEIPDB_KEY), "" while it is unset and none is. A score
|
||||||
// of AbuseIPDBMinScore or more is a hit (SWWAF_ABUSEIPDB_MIN_SCORE), and
|
// of AbuseIPDBMinScore or more is a hit (SWW_ABUSEIPDB_MIN_SCORE), and
|
||||||
// at most AbuseIPDBDailyBudget checks are made a day
|
// at most AbuseIPDBDailyBudget checks are made a day
|
||||||
// (SWWAF_ABUSEIPDB_DAILY_BUDGET).
|
// (SWW_ABUSEIPDB_DAILY_BUDGET).
|
||||||
// ReputationAction is what is done with a client a zone's verdict lists,
|
// ReputationAction is what is done with a client a zone's verdict lists,
|
||||||
// or whose score is a hit (SWWAF_REPUTATION_ACTION): deny, limit or log;
|
// or whose score is a hit (SWW_REPUTATION_ACTION): deny, limit or log;
|
||||||
// for limit, ReputationLimitPercent is the percentage of every limit it
|
// for limit, ReputationLimitPercent is the percentage of every limit it
|
||||||
// gets. A verdict or a score is used for ReputationCacheTTL after it was
|
// gets. A verdict or a score is used for ReputationCacheTTL after it was
|
||||||
// fetched (SWWAF_REPUTATION_CACHE_TTL), and a query or a check may take
|
// fetched (SWW_REPUTATION_CACHE_TTL), and a query or a check may take
|
||||||
// ReputationTimeout (SWWAF_REPUTATION_TIMEOUT). Neither can be off.
|
// ReputationTimeout (SWW_REPUTATION_TIMEOUT). Neither can be off.
|
||||||
DNSBLZones []string
|
DNSBLZones []string
|
||||||
DNSBLResolver netip.AddrPort
|
DNSBLResolver netip.AddrPort
|
||||||
AbuseIPDBKey string
|
AbuseIPDBKey string
|
||||||
@@ -155,94 +155,97 @@ type Config struct {
|
|||||||
ReputationCacheTTL time.Duration
|
ReputationCacheTTL time.Duration
|
||||||
ReputationTimeout time.Duration
|
ReputationTimeout time.Duration
|
||||||
// CrowdSecDecisionsURL is where the decision list of the CrowdSec
|
// CrowdSecDecisionsURL is where the decision list of the CrowdSec
|
||||||
// engine whose local API SWWAF_CROWDSEC_LAPI_URL names is fetched from:
|
// engine whose local API SWW_CROWDSEC_LAPI_URL names is fetched from:
|
||||||
// that URL with v1/decisions added to its path, "" while it is unset and
|
// that URL with v1/decisions added to its path, "" while it is unset and
|
||||||
// none is. CrowdSecKey is the key the engine is asked with
|
// none is. CrowdSecKey is the key the engine is asked with
|
||||||
// (SWWAF_CROWDSEC_LAPI_KEY).
|
// (SWW_CROWDSEC_LAPI_KEY).
|
||||||
CrowdSecDecisionsURL string
|
CrowdSecDecisionsURL string
|
||||||
CrowdSecKey string
|
CrowdSecKey string
|
||||||
// BanResponse is the status a refused client is answered with, 403
|
// BanResponse is the status a refused client is answered with, 403
|
||||||
// or 429, or 0 to close the connection without an answer
|
// or 429, or 0 to close the connection without an answer
|
||||||
// (SWWAF_BAN_RESPONSE). It answers a banned client, a request that
|
// (SWW_BAN_RESPONSE). It answers a banned client, a request that
|
||||||
// breaks a rate limit or matches a ban rule, SWWAF_DENY_NETS and the
|
// breaks a rate limit or matches a ban rule, SWW_DENY_NETS and the
|
||||||
// country lists.
|
// country lists.
|
||||||
BanResponse int
|
BanResponse int
|
||||||
// LimitBanDuration is the ban for a first broken rate limit
|
// LimitBanDuration is the ban for a first broken rate limit
|
||||||
// (SWWAF_LIMIT_BAN_DURATION). A limit broken again within
|
// (SWW_LIMIT_BAN_DURATION). A limit broken again within
|
||||||
// LimitBanRepeatWindow after the last ban ended bans for three times
|
// LimitBanRepeatWindow after the last ban ended bans for three times
|
||||||
// as long as that ban (SWWAF_LIMIT_BAN_REPEAT_WINDOW), and a ban that
|
// as long as that ban (SWW_LIMIT_BAN_REPEAT_WINDOW), and a ban that
|
||||||
// would be longer than MaxBanDuration is permanent instead
|
// would be longer than MaxBanDuration is permanent instead
|
||||||
// (SWWAF_MAX_BAN_DURATION). None of them can be off.
|
// (SWW_MAX_BAN_DURATION). None of them can be off.
|
||||||
LimitBanDuration time.Duration
|
LimitBanDuration time.Duration
|
||||||
LimitBanRepeatWindow time.Duration
|
LimitBanRepeatWindow time.Duration
|
||||||
MaxBanDuration time.Duration
|
MaxBanDuration time.Duration
|
||||||
// AttackBanDuration is the ban for a first clear sign of attack
|
// AttackBanDuration is the ban for a first clear sign of attack
|
||||||
// (SWWAF_ATTACK_BAN_DURATION). It cannot be off.
|
// (SWW_ATTACK_BAN_DURATION). It cannot be off.
|
||||||
AttackBanDuration time.Duration
|
AttackBanDuration time.Duration
|
||||||
// MaxBans is the most bans held (SWWAF_MAX_BANS).
|
// MaxBans is the most bans held (SWW_MAX_BANS).
|
||||||
MaxBans int
|
MaxBans int
|
||||||
// BanScopeV4Prefix is the length of the netblock around an IPv4
|
// BanScopeV4Prefix is the length of the netblock around an IPv4
|
||||||
// client that a ban covers (SWWAF_BAN_SCOPE_V4_PREFIX).
|
// client that a ban covers (SWW_BAN_SCOPE_V4_PREFIX).
|
||||||
BanScopeV4Prefix int
|
BanScopeV4Prefix int
|
||||||
// StateDir is the directory of the state files, an absolute path
|
// StateDir is the directory of the state files, an absolute path
|
||||||
// (SWWAF_STATE_DIR). bans.json is written StateWriteDelay after a ban
|
// (SWW_STATE_DIR). bans.json is written StateWriteDelay after a ban
|
||||||
// is made (SWWAF_STATE_WRITE_DELAY), and every state file every
|
// is made (SWW_STATE_WRITE_DELAY), and every state file every
|
||||||
// StateCounterInterval (SWWAF_STATE_COUNTER_INTERVAL). Neither can be
|
// StateCounterInterval (SWW_STATE_COUNTER_INTERVAL). Neither can be
|
||||||
// off.
|
// off.
|
||||||
StateDir string
|
StateDir string
|
||||||
StateWriteDelay time.Duration
|
StateWriteDelay time.Duration
|
||||||
StateCounterInterval time.Duration
|
StateCounterInterval time.Duration
|
||||||
// LogRequestHeaders are the request headers whose values the request
|
// LogRequestHeaders are the request headers whose values the request
|
||||||
// log gives, in lower case (SWWAF_LOG_REQUEST_HEADERS).
|
// log gives, in lower case (SWW_LOG_REQUEST_HEADERS).
|
||||||
LogRequestHeaders []string
|
LogRequestHeaders []string
|
||||||
// LogLevel is the least severe of the process's own messages that are
|
// LogLevel is the least severe of the process's own messages that are
|
||||||
// written (SWWAF_LOG_LEVEL). It holds back no request log line.
|
// written (SWW_LOG_LEVEL). It holds back no request log line.
|
||||||
LogLevel slog.Level
|
LogLevel slog.Level
|
||||||
// AdminToken is the bearer token an admin sends for the ban endpoints
|
// AdminToken is the bearer token an admin sends for the ban endpoints
|
||||||
// and /_smallwebwaf/clients/<ip> (SWWAF_ADMIN_TOKEN), "" while it is
|
// and /_sww/clients/<ip> (SWW_ADMIN_TOKEN), "" while it is
|
||||||
// unset and they are off.
|
// unset and they are off.
|
||||||
AdminToken string
|
AdminToken string
|
||||||
// MetricsToken is the bearer token a scraper sends for the metrics
|
// MetricsToken is the bearer token a scraper sends for the metrics
|
||||||
// (SWWAF_METRICS_TOKEN), "" while it is unset and the metrics are off.
|
// (SWW_METRICS_TOKEN), "" while it is unset and the metrics are off.
|
||||||
// MetricsTopN is how many AS numbers and how many countries get series
|
// MetricsTopN is how many AS numbers and how many countries get series
|
||||||
// of their own in the metrics (SWWAF_METRICS_TOP_N).
|
// of their own in the metrics (SWW_METRICS_TOP_N).
|
||||||
MetricsToken string
|
MetricsToken string
|
||||||
MetricsTopN int
|
MetricsTopN int
|
||||||
// RulesDir is the directory of the rule files (SWWAF_RULES_DIR), read
|
// RulesDir is the directory of the rule files (SWW_RULES_DIR), read
|
||||||
// unless RulesEnabled is false (SWWAF_RULES_ENABLED).
|
// unless RulesEnabled is false (SWW_RULES_ENABLED).
|
||||||
RulesDir string
|
RulesDir string
|
||||||
RulesEnabled bool
|
RulesEnabled bool
|
||||||
// WAFMode is what the Core Rule Set does (SWWAF_WAF_MODE): WAFModeOff,
|
// WAFMode is what the Core Rule Set does (SWW_WAF_MODE): WAFModeOff,
|
||||||
// WAFModeDetect or WAFModeBlock. WAFParanoiaLevel is its paranoia
|
// WAFModeDetect or WAFModeBlock. WAFParanoiaLevel is its paranoia
|
||||||
// level, from 1 to 4 (SWWAF_WAF_PARANOIA_LEVEL), and
|
// level, from 1 to 4 (SWW_WAF_PARANOIA_LEVEL), and
|
||||||
// WAFAnomalyThreshold the anomaly score at which a request is a match
|
// WAFAnomalyThreshold the anomaly score at which a request is a match
|
||||||
// (SWWAF_WAF_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules
|
// (SWW_WAF_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules
|
||||||
// are the ids of its rules switched off (SWWAF_WAF_DISABLED_RULES),
|
// are the ids of its rules switched off (SWW_WAF_DISABLED_RULES),
|
||||||
// WAFExemptPaths the path prefixes it does not inspect
|
// WAFExemptPaths the path prefixes it does not inspect
|
||||||
// (SWWAF_WAF_EXEMPT_PATHS), and WAFBodyLimit the most of a request body
|
// (SWW_WAF_EXEMPT_PATHS), and WAFBodyLimit the most of a request body
|
||||||
// it reads (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none.
|
// it reads (SWW_WAF_BODY_LIMIT), 0 while it is off and it reads none.
|
||||||
|
// CoreRuleSetExclusionsFile is the path of the file of its exclusions
|
||||||
|
// (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while there is none.
|
||||||
WAFMode string
|
WAFMode string
|
||||||
WAFParanoiaLevel int
|
WAFParanoiaLevel int
|
||||||
WAFAnomalyThreshold int
|
WAFAnomalyThreshold int
|
||||||
WAFDisabledRules []int
|
WAFDisabledRules []int
|
||||||
WAFExemptPaths []string
|
WAFExemptPaths []string
|
||||||
WAFBodyLimit int64
|
WAFBodyLimit int64
|
||||||
|
CoreRuleSetExclusionsFile string
|
||||||
// TrapPaths are the paths a request for which is a clear sign of
|
// TrapPaths are the paths a request for which is a clear sign of
|
||||||
// attack (SWWAF_TRAP_PATHS), each starting with / and without a ?.
|
// attack (SWW_TRAP_PATHS), each starting with / and without a ?.
|
||||||
TrapPaths []string
|
TrapPaths []string
|
||||||
// ErrorBurstThreshold is the most requests of a client within a minute
|
// ErrorBurstThreshold is the most requests of a client within a minute
|
||||||
// that servewaf 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 (SWWAF_ERROR_BURST_THRESHOLD). 0 is off.
|
// more breaks a limit (SWW_ERROR_BURST_THRESHOLD). 0 is off.
|
||||||
ErrorBurstThreshold int64
|
ErrorBurstThreshold int64
|
||||||
// LogRemoteURL is where every line on stdout is also sent
|
// LogRemoteURL is where every line on stdout is also sent
|
||||||
// (SWWAF_LOG_REMOTE_URL), nil while it is unset and nothing is sent.
|
// (SWW_LOG_REMOTE_URL), nil while it is unset and nothing is sent.
|
||||||
// LogRemoteTLSCAs are the certificates a syslog+tls endpoint's
|
// LogRemoteTLSCAs are the certificates a syslog+tls endpoint's
|
||||||
// certificate must chain to (SWWAF_LOG_REMOTE_TLS_CA_FILE), nil while
|
// certificate must chain to (SWW_LOG_REMOTE_TLS_CA_FILE), nil while
|
||||||
// it is unset and the host's own are used. LogRemoteBuffer is the most
|
// it is unset and the host's own are used. LogRemoteBuffer is the most
|
||||||
// lines held while they wait to be sent (SWWAF_LOG_REMOTE_BUFFER).
|
// lines held while they wait to be sent (SWW_LOG_REMOTE_BUFFER).
|
||||||
// LogRemoteFacility is the number of the syslog facility
|
// LogRemoteFacility is the number of the syslog facility
|
||||||
// (SWWAF_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME
|
// (SWW_LOG_REMOTE_FACILITY), and LogRemoteAppName the APP-NAME
|
||||||
// (SWWAF_LOG_REMOTE_APP_NAME, by default InstanceName), of the records
|
// (SWW_LOG_REMOTE_APP_NAME, by default InstanceName), of the records
|
||||||
// the lines are sent in.
|
// the lines are sent in.
|
||||||
LogRemoteURL *url.URL
|
LogRemoteURL *url.URL
|
||||||
LogRemoteTLSCAs *x509.CertPool
|
LogRemoteTLSCAs *x509.CertPool
|
||||||
@@ -250,18 +253,18 @@ type Config struct {
|
|||||||
LogRemoteFacility int
|
LogRemoteFacility int
|
||||||
LogRemoteAppName string
|
LogRemoteAppName string
|
||||||
// AlertWebhookURL is where each alert is posted as JSON
|
// AlertWebhookURL is where each alert is posted as JSON
|
||||||
// (SWWAF_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders
|
// (SWW_ALERT_WEBHOOK_URL), nil while it is unset. AlertWebhookHeaders
|
||||||
// are sent with each (SWWAF_ALERT_WEBHOOK_HEADERS).
|
// are sent with each (SWW_ALERT_WEBHOOK_HEADERS).
|
||||||
// AlertSlackWebhookURL is the Slack incoming webhook each alert is
|
// AlertSlackWebhookURL is the Slack incoming webhook each alert is
|
||||||
// posted to as a message (SWWAF_ALERT_SLACK_WEBHOOK_URL), and
|
// posted to as a message (SWW_ALERT_SLACK_WEBHOOK_URL), and
|
||||||
// AlertNtfyURL the ntfy topic each is published to
|
// AlertNtfyURL the ntfy topic each is published to
|
||||||
// (SWWAF_ALERT_NTFY_URL), each nil while it is unset; AlertNtfyToken,
|
// (SWW_ALERT_NTFY_URL), each nil while it is unset; AlertNtfyToken,
|
||||||
// unless empty, is sent to ntfy with each (SWWAF_ALERT_NTFY_TOKEN).
|
// unless empty, is sent to ntfy with each (SWW_ALERT_NTFY_TOKEN).
|
||||||
// With none of the three URLs set, no alert is sent. AlertEvents are
|
// With none of the three URLs set, no alert is sent. AlertEvents are
|
||||||
// the events alerts are sent for (SWWAF_ALERT_EVENTS). A repeat of an
|
// the events alerts are sent for (SWW_ALERT_EVENTS). A repeat of an
|
||||||
// alert within AlertCooldown is held back (SWWAF_ALERT_COOLDOWN), and
|
// alert within AlertCooldown is held back (SWW_ALERT_COOLDOWN), and
|
||||||
// so is an alert past AlertMaxPerHour in an hour, for the hour's
|
// so is an alert past AlertMaxPerHour in an hour, for the hour's
|
||||||
// summary (SWWAF_ALERT_MAX_PER_HOUR); 0 is off for both.
|
// summary (SWW_ALERT_MAX_PER_HOUR); 0 is off for both.
|
||||||
AlertWebhookURL *url.URL
|
AlertWebhookURL *url.URL
|
||||||
AlertWebhookHeaders http.Header
|
AlertWebhookHeaders http.Header
|
||||||
AlertSlackWebhookURL *url.URL
|
AlertSlackWebhookURL *url.URL
|
||||||
@@ -271,14 +274,14 @@ type Config struct {
|
|||||||
AlertCooldown time.Duration
|
AlertCooldown time.Duration
|
||||||
AlertMaxPerHour int
|
AlertMaxPerHour int
|
||||||
// The anomaly thresholds, which only raise alerts: the most requests
|
// The anomaly thresholds, which only raise alerts: the most requests
|
||||||
// and bytes a minute and an hour per client (SWWAF_ANOMALY_CLIENT_*),
|
// and bytes a minute and an hour per client (SWW_ANOMALY_CLIENT_*),
|
||||||
// per netblock around a client (SWWAF_ANOMALY_NET_*), per AS number
|
// per netblock around a client (SWW_ANOMALY_NET_*), per AS number
|
||||||
// (SWWAF_ANOMALY_ASN_*), for the whole service (SWWAF_ANOMALY_TOTAL_*)
|
// (SWW_ANOMALY_ASN_*), for the whole service (SWW_ANOMALY_TOTAL_*)
|
||||||
// and per named netblock (SWWAF_WATCH_*), each 0 while it is off.
|
// and per named netblock (SWW_WATCH_*), each 0 while it is off.
|
||||||
// AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths of the
|
// AnomalyNetV4Prefix and AnomalyNetV6Prefix are the lengths of the
|
||||||
// netblock around a client (SWWAF_ANOMALY_NET_V4_PREFIX and
|
// netblock around a client (SWW_ANOMALY_NET_V4_PREFIX and
|
||||||
// SWWAF_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks
|
// SWW_ANOMALY_NET_V6_PREFIX), and WatchNets the named netblocks
|
||||||
// (SWWAF_WATCH_NETS).
|
// (SWW_WATCH_NETS).
|
||||||
AnomalyClient anomaly.Thresholds
|
AnomalyClient anomaly.Thresholds
|
||||||
AnomalyNet anomaly.Thresholds
|
AnomalyNet anomaly.Thresholds
|
||||||
AnomalyASN anomaly.Thresholds
|
AnomalyASN anomaly.Thresholds
|
||||||
@@ -297,7 +300,7 @@ type Config struct {
|
|||||||
// off.
|
// off.
|
||||||
const off = "off"
|
const off = "off"
|
||||||
|
|
||||||
// The values of SWWAF_WAF_MODE.
|
// The values of SWW_WAF_MODE.
|
||||||
const (
|
const (
|
||||||
// WAFModeOff runs no request through the Core Rule Set.
|
// WAFModeOff runs no request through the Core Rule Set.
|
||||||
WAFModeOff = off
|
WAFModeOff = off
|
||||||
@@ -307,8 +310,8 @@ const (
|
|||||||
WAFModeBlock = "block"
|
WAFModeBlock = "block"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fileSource is the SWWAF_LOOKUP_SOURCE that looks clients up in the
|
// fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the
|
||||||
// lookup database, the file SWWAF_LOOKUP_DB_PATH names.
|
// lookup database, the file SWW_LOOKUP_DB_PATH names.
|
||||||
const fileSource = "file"
|
const fileSource = "file"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -318,7 +321,7 @@ const (
|
|||||||
gibibyte = 1 << 30
|
gibibyte = 1 << 30
|
||||||
ipv4Bits = 32
|
ipv4Bits = 32
|
||||||
ipv6Bits = 128
|
ipv6Bits = 128
|
||||||
// minIPv6GroupPrefix is the shortest SWWAF_IPV6_GROUP_PREFIX, the
|
// minIPv6GroupPrefix is the shortest SWW_IPV6_GROUP_PREFIX, the
|
||||||
// netblock a provider is usually given: a shorter one would make one
|
// netblock a provider is usually given: a shorter one would make one
|
||||||
// client of the customers of several providers.
|
// client of the customers of several providers.
|
||||||
minIPv6GroupPrefix = 32
|
minIPv6GroupPrefix = 32
|
||||||
@@ -327,7 +330,7 @@ const (
|
|||||||
// maxParanoiaLevel is the Core Rule Set's highest paranoia level.
|
// maxParanoiaLevel is the Core Rule Set's highest paranoia level.
|
||||||
maxParanoiaLevel = 4
|
maxParanoiaLevel = 4
|
||||||
// firstSetupRuleID to lastSetupRuleID are the ids the Core Rule Set
|
// firstSetupRuleID to lastSetupRuleID are the ids the Core Rule Set
|
||||||
// keeps for the rules that set it up, which smallwebwaf's own rules
|
// keeps for the rules that set it up, which sww's own rules
|
||||||
// have too (see internal/waf). Switching one off would undo a change
|
// have too (see internal/waf). Switching one off would undo a change
|
||||||
// that no setting undoes.
|
// that no setting undoes.
|
||||||
firstSetupRuleID = 900000
|
firstSetupRuleID = 900000
|
||||||
@@ -335,7 +338,7 @@ const (
|
|||||||
// masked is what the log shows for a token that is set, and in place of
|
// masked is what the log shows for a token that is set, and in place of
|
||||||
// a secret in another setting.
|
// a secret in another setting.
|
||||||
masked = "********"
|
masked = "********"
|
||||||
// defaultListenAddr is the default of SWWAF_LISTEN_ADDR and
|
// defaultListenAddr is the default of SWW_LISTEN_ADDR and
|
||||||
// SWRP_LISTEN_ADDR, and defaultUpstreamURL that of SWRP_UPSTREAM_URL.
|
// SWRP_LISTEN_ADDR, and defaultUpstreamURL that of SWRP_UPSTREAM_URL.
|
||||||
defaultListenAddr = ":8080"
|
defaultListenAddr = ":8080"
|
||||||
defaultUpstreamURL = "http://127.0.0.1:8081"
|
defaultUpstreamURL = "http://127.0.0.1:8081"
|
||||||
@@ -364,14 +367,14 @@ var (
|
|||||||
errHeaderTakenOut = errors.New(
|
errHeaderTakenOut = errors.New(
|
||||||
"is taken out of every request by Go's HTTP server, so it can never " +
|
"is taken out of every request by Go's HTTP server, so it can never " +
|
||||||
"be logged")
|
"be logged")
|
||||||
errOnBothLists = errors.New("is in SWWAF_DENIED_COUNTRIES too")
|
errOnBothLists = errors.New("is in SWW_DENIED_COUNTRIES too")
|
||||||
errNotLookupSource = errors.New("is not geojs, file or off")
|
errNotLookupSource = errors.New("is not geojs, file or off")
|
||||||
errNeedsLookups = errors.New("it needs each client looked up")
|
errNeedsLookups = errors.New("it needs each client looked up")
|
||||||
errNeedsDBPath = errors.New("it names the file to look clients up in")
|
errNeedsDBPath = errors.New("it names the file to look clients up in")
|
||||||
errDBPathUnused = errors.New("only file reads it")
|
errDBPathUnused = errors.New("only file reads it")
|
||||||
errOver64K = errors.New(
|
errOver64K = errors.New(
|
||||||
"is more than 64K, the most servewaf gets of a body")
|
"is more than 64K, the most discriminator gets of a body")
|
||||||
errNotCommand = errors.New("is not rproxy or servewaf")
|
errNotCommand = errors.New("is not rproxy or discriminator")
|
||||||
errNotDurationAboveZero = errors.New(
|
errNotDurationAboveZero = errors.New(
|
||||||
"is not a duration above zero, such as 1h or 7d")
|
"is not a duration above zero, such as 1h or 7d")
|
||||||
errNotNumberAboveZero = errors.New(
|
errNotNumberAboveZero = errors.New(
|
||||||
@@ -387,7 +390,7 @@ var (
|
|||||||
errNotNamedNetblock = errors.New(
|
errNotNamedNetblock = errors.New(
|
||||||
"is not a name, = and a netblock, such as office=203.0.113.0/24")
|
"is not a name, = and a netblock, such as office=203.0.113.0/24")
|
||||||
errNotAbsolutePath = errors.New(
|
errNotAbsolutePath = errors.New(
|
||||||
"is not an absolute path, such as /var/lib/smallwebwaf")
|
"is not an absolute path, such as /var/lib/sww")
|
||||||
errShortToken = errors.New("is shorter than 32 characters")
|
errShortToken = errors.New("is shorter than 32 characters")
|
||||||
errNotMode = errors.New("is not enforce or observe")
|
errNotMode = errors.New("is not enforce or observe")
|
||||||
errNotBytesCount = errors.New("is not response, request or both")
|
errNotBytesCount = errors.New("is not response, request or both")
|
||||||
@@ -401,7 +404,7 @@ var (
|
|||||||
"is not the id of a Core Rule Set rule, a whole number such as 942100")
|
"is not the id of a Core Rule Set rule, a whole number such as 942100")
|
||||||
errSetupRuleID = errors.New(
|
errSetupRuleID = errors.New(
|
||||||
"is from 900000 to 900999, the ids of the rules that set the Core Rule Set " +
|
"is from 900000 to 900999, the ids of the rules that set the Core Rule Set " +
|
||||||
"up and of smallwebwaf's own, which cannot be switched off")
|
"up and of sww's own, which cannot be switched off")
|
||||||
errNotBoolean = errors.New("is not true or false")
|
errNotBoolean = errors.New("is not true or false")
|
||||||
errNotLogRemoteURL = errors.New(
|
errNotLogRemoteURL = errors.New(
|
||||||
"is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " +
|
"is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " +
|
||||||
@@ -413,7 +416,7 @@ var (
|
|||||||
errSetTwice = errors.New("set only one of them")
|
errSetTwice = errors.New("set only one of them")
|
||||||
errNotWebhookURL = errors.New(
|
errNotWebhookURL = errors.New(
|
||||||
"is not an http or https URL without a user or a fragment, " +
|
"is not an http or https URL without a user or a fragment, " +
|
||||||
"such as https://alerts.example/smallwebwaf")
|
"such as https://alerts.example/sww")
|
||||||
errNotWebhookHeader = errors.New(
|
errNotWebhookHeader = errors.New(
|
||||||
"is not a header name followed by : and the header's value, " +
|
"is not a header name followed by : and the header's value, " +
|
||||||
"such as Authorization:Bearer <token>")
|
"such as Authorization:Bearer <token>")
|
||||||
@@ -432,7 +435,7 @@ var (
|
|||||||
errNotListURL = errors.New(
|
errNotListURL = errors.New(
|
||||||
"is not an http or https URL without a user or a fragment, " +
|
"is not an http or https URL without a user or a fragment, " +
|
||||||
"such as https://www.spamhaus.org/drop/drop.txt")
|
"such as https://www.spamhaus.org/drop/drop.txt")
|
||||||
errInBlocklistURLs = errors.New("is in SWWAF_BLOCKLIST_URLS too")
|
errInBlocklistURLs = errors.New("is in SWW_BLOCKLIST_URLS too")
|
||||||
errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h")
|
errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h")
|
||||||
errNotAction = errors.New(
|
errNotAction = errors.New(
|
||||||
"is not deny, limit:<percent> such as limit:25, or log")
|
"is not deny, limit:<percent> such as limit:25, or log")
|
||||||
@@ -447,7 +450,7 @@ var (
|
|||||||
errNeedsLAPIKey = errors.New("the engine answers no request without it")
|
errNeedsLAPIKey = errors.New("the engine answers no request without it")
|
||||||
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
|
errLAPIKeyUnused = errors.New("it is sent only to the engine at that URL")
|
||||||
errAnotherList = errors.New(
|
errAnotherList = errors.New(
|
||||||
"is in SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too")
|
"is in SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too")
|
||||||
)
|
)
|
||||||
|
|
||||||
// FromEnvironment reads the settings with lookupEnv, normally
|
// FromEnvironment reads the settings with lookupEnv, normally
|
||||||
@@ -460,102 +463,104 @@ var (
|
|||||||
func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||||
env := &environment{lookupEnv: lookupEnv}
|
env := &environment{lookupEnv: lookupEnv}
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
ListenAddr: env.address("SWWAF_LISTEN_ADDR"),
|
ListenAddr: env.address("SWW_LISTEN_ADDR"),
|
||||||
InstanceName: env.instanceName(),
|
InstanceName: env.instanceName(),
|
||||||
Observe: env.observe("SWWAF_MODE", "enforce"),
|
Observe: env.observe("SWW_MODE", "enforce"),
|
||||||
IPv6GroupPrefix: env.ipv6GroupPrefix("SWWAF_IPV6_GROUP_PREFIX", "64"),
|
IPv6GroupPrefix: env.ipv6GroupPrefix("SWW_IPV6_GROUP_PREFIX", "64"),
|
||||||
MaxTrackedClients: env.numberNotOff("SWWAF_MAX_TRACKED_CLIENTS", "20000"),
|
MaxTrackedClients: env.numberNotOff("SWW_MAX_TRACKED_CLIENTS", "20000"),
|
||||||
AllowNets: env.netblocks("SWWAF_ALLOW_NETS", ""),
|
AllowNets: env.netblocks("SWW_ALLOW_NETS", ""),
|
||||||
RateLimitExemptNets: env.netblocks("SWWAF_RATE_LIMIT_EXEMPT_NETS", ""),
|
RateLimitExemptNets: env.netblocks("SWW_RATE_LIMIT_EXEMPT_NETS", ""),
|
||||||
DenyNets: env.netblocks("SWWAF_DENY_NETS", ""),
|
DenyNets: env.netblocks("SWW_DENY_NETS", ""),
|
||||||
RateLimitPerMinute: env.count("SWWAF_RATE_LIMIT_PER_MINUTE", "120"),
|
RateLimitPerMinute: env.count("SWW_RATE_LIMIT_PER_MINUTE", "120"),
|
||||||
RateLimitPerHour: env.count("SWWAF_RATE_LIMIT_PER_HOUR", "1500"),
|
RateLimitPerHour: env.count("SWW_RATE_LIMIT_PER_HOUR", "1500"),
|
||||||
RateLimitPerDay: env.count("SWWAF_RATE_LIMIT_PER_DAY", "6000"),
|
RateLimitPerDay: env.count("SWW_RATE_LIMIT_PER_DAY", "6000"),
|
||||||
RateLimitExemptPaths: env.pathPrefixes("SWWAF_RATE_LIMIT_EXEMPT_PATHS", ""),
|
RateLimitExemptPaths: env.pathPrefixes("SWW_RATE_LIMIT_EXEMPT_PATHS", ""),
|
||||||
BytesLimitPerMinute: env.size("SWWAF_BYTES_LIMIT_PER_MINUTE", "off"),
|
BytesLimitPerMinute: env.size("SWW_BYTES_LIMIT_PER_MINUTE", "off"),
|
||||||
BytesLimitPerHour: env.size("SWWAF_BYTES_LIMIT_PER_HOUR", "50G"),
|
BytesLimitPerHour: env.size("SWW_BYTES_LIMIT_PER_HOUR", "50G"),
|
||||||
BytesLimitPerDay: env.size("SWWAF_BYTES_LIMIT_PER_DAY", "100G"),
|
BytesLimitPerDay: env.size("SWW_BYTES_LIMIT_PER_DAY", "100G"),
|
||||||
BytesCount: env.bytesCount("SWWAF_BYTES_COUNT", "both"),
|
BytesCount: env.bytesCount("SWW_BYTES_COUNT", "both"),
|
||||||
LookupSource: env.lookupSource("SWWAF_LOOKUP_SOURCE", "geojs"),
|
LookupSource: env.lookupSource("SWW_LOOKUP_SOURCE", "geojs"),
|
||||||
LookupDBPath: env.value("SWWAF_LOOKUP_DB_PATH", ""),
|
LookupDBPath: env.value("SWW_LOOKUP_DB_PATH", ""),
|
||||||
LookupTimeout: env.durationNotOff("SWWAF_LOOKUP_TIMEOUT", "800ms"),
|
LookupTimeout: env.durationNotOff("SWW_LOOKUP_TIMEOUT", "800ms"),
|
||||||
AddLookupHeaders: env.boolean("SWWAF_ADD_LOOKUP_HEADERS", "false"),
|
AddLookupHeaders: env.boolean("SWW_ADD_LOOKUP_HEADERS", "false"),
|
||||||
DeniedCountries: env.countries("SWWAF_DENIED_COUNTRIES", ""),
|
DeniedCountries: env.countries("SWW_DENIED_COUNTRIES", ""),
|
||||||
ExclusivelyAllowedCountries: env.countries(
|
ExclusivelyAllowedCountries: env.countries(
|
||||||
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
|
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
|
||||||
ASNLimitPercent: env.percents("SWWAF_ASN_LIMIT_PERCENT", ParseASN),
|
ASNLimitPercent: env.percents("SWW_ASN_LIMIT_PERCENT", ParseASN),
|
||||||
CountryLimitPercent: env.percents("SWWAF_COUNTRY_LIMIT_PERCENT", parseCountry),
|
CountryLimitPercent: env.percents("SWW_COUNTRY_LIMIT_PERCENT", parseCountry),
|
||||||
ASNBytesPercent: env.percents("SWWAF_ASN_BYTES_PERCENT", ParseASN),
|
ASNBytesPercent: env.percents("SWW_ASN_BYTES_PERCENT", ParseASN),
|
||||||
CountryBytesPercent: env.percents("SWWAF_COUNTRY_BYTES_PERCENT", parseCountry),
|
CountryBytesPercent: env.percents("SWW_COUNTRY_BYTES_PERCENT", parseCountry),
|
||||||
UnknownLimitPercent: env.percent("SWWAF_UNKNOWN_LIMIT_PERCENT", "100"),
|
UnknownLimitPercent: env.percent("SWW_UNKNOWN_LIMIT_PERCENT", "100"),
|
||||||
ASNLimitPercentURL: env.listURL("SWWAF_ASN_LIMIT_PERCENT_URL"),
|
ASNLimitPercentURL: env.listURL("SWW_ASN_LIMIT_PERCENT_URL"),
|
||||||
BlocklistURLs: env.listURLs("SWWAF_BLOCKLIST_URLS"),
|
BlocklistURLs: env.listURLs("SWW_BLOCKLIST_URLS"),
|
||||||
BlocklistRefresh: env.refresh("SWWAF_BLOCKLIST_REFRESH", "24h"),
|
BlocklistRefresh: env.refresh("SWW_BLOCKLIST_REFRESH", "24h"),
|
||||||
DNSBLZones: env.zones("SWWAF_DNSBL_ZONES"),
|
DNSBLZones: env.zones("SWW_DNSBL_ZONES"),
|
||||||
DNSBLResolver: env.resolver("SWWAF_DNSBL_RESOLVER"),
|
DNSBLResolver: env.resolver("SWW_DNSBL_RESOLVER"),
|
||||||
AbuseIPDBKey: env.secret("SWWAF_ABUSEIPDB_KEY"),
|
AbuseIPDBKey: env.secret("SWW_ABUSEIPDB_KEY"),
|
||||||
AbuseIPDBMinScore: env.percent("SWWAF_ABUSEIPDB_MIN_SCORE", "75"),
|
AbuseIPDBMinScore: env.percent("SWW_ABUSEIPDB_MIN_SCORE", "75"),
|
||||||
AbuseIPDBDailyBudget: env.numberNotOff("SWWAF_ABUSEIPDB_DAILY_BUDGET", "900"),
|
AbuseIPDBDailyBudget: env.numberNotOff("SWW_ABUSEIPDB_DAILY_BUDGET", "900"),
|
||||||
ReputationCacheTTL: env.durationNotOff("SWWAF_REPUTATION_CACHE_TTL", "24h"),
|
ReputationCacheTTL: env.durationNotOff("SWW_REPUTATION_CACHE_TTL", "24h"),
|
||||||
ReputationTimeout: env.durationNotOff("SWWAF_REPUTATION_TIMEOUT", "2s"),
|
ReputationTimeout: env.durationNotOff("SWW_REPUTATION_TIMEOUT", "2s"),
|
||||||
CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWWAF_CROWDSEC_LAPI_URL"),
|
CrowdSecDecisionsURL: env.crowdSecDecisionsURL("SWW_CROWDSEC_LAPI_URL"),
|
||||||
CrowdSecKey: env.secret("SWWAF_CROWDSEC_LAPI_KEY"),
|
CrowdSecKey: env.secret("SWW_CROWDSEC_LAPI_KEY"),
|
||||||
BanResponse: env.banResponse("SWWAF_BAN_RESPONSE", "403"),
|
BanResponse: env.banResponse("SWW_BAN_RESPONSE", "403"),
|
||||||
LimitBanDuration: env.durationNotOff("SWWAF_LIMIT_BAN_DURATION", "1h"),
|
LimitBanDuration: env.durationNotOff("SWW_LIMIT_BAN_DURATION", "1h"),
|
||||||
LimitBanRepeatWindow: env.durationNotOff("SWWAF_LIMIT_BAN_REPEAT_WINDOW", "24h"),
|
LimitBanRepeatWindow: env.durationNotOff("SWW_LIMIT_BAN_REPEAT_WINDOW", "24h"),
|
||||||
MaxBanDuration: env.durationNotOff("SWWAF_MAX_BAN_DURATION", "7d"),
|
MaxBanDuration: env.durationNotOff("SWW_MAX_BAN_DURATION", "7d"),
|
||||||
AttackBanDuration: env.durationNotOff("SWWAF_ATTACK_BAN_DURATION", "7d"),
|
AttackBanDuration: env.durationNotOff("SWW_ATTACK_BAN_DURATION", "7d"),
|
||||||
MaxBans: env.numberNotOff("SWWAF_MAX_BANS", "5000"),
|
MaxBans: env.numberNotOff("SWW_MAX_BANS", "5000"),
|
||||||
BanScopeV4Prefix: env.v4Prefix("SWWAF_BAN_SCOPE_V4_PREFIX", "32"),
|
BanScopeV4Prefix: env.v4Prefix("SWW_BAN_SCOPE_V4_PREFIX", "32"),
|
||||||
StateDir: env.absolutePath("SWWAF_STATE_DIR", "/var/lib/smallwebwaf"),
|
StateDir: env.absolutePath("SWW_STATE_DIR", "/var/lib/sww"),
|
||||||
StateWriteDelay: env.durationNotOff("SWWAF_STATE_WRITE_DELAY", "10s"),
|
StateWriteDelay: env.durationNotOff("SWW_STATE_WRITE_DELAY", "10s"),
|
||||||
StateCounterInterval: env.durationNotOff("SWWAF_STATE_COUNTER_INTERVAL", "15m"),
|
StateCounterInterval: env.durationNotOff("SWW_STATE_COUNTER_INTERVAL", "15m"),
|
||||||
LogRequestHeaders: env.headerNames("SWWAF_LOG_REQUEST_HEADERS",
|
LogRequestHeaders: env.headerNames("SWW_LOG_REQUEST_HEADERS",
|
||||||
"accept,accept-language,accept-encoding,content-type,origin,range"),
|
"accept,accept-language,accept-encoding,content-type,origin,range"),
|
||||||
LogLevel: env.logLevel("SWWAF_LOG_LEVEL", "info"),
|
LogLevel: env.logLevel("SWW_LOG_LEVEL", "info"),
|
||||||
AdminToken: env.token("SWWAF_ADMIN_TOKEN"),
|
AdminToken: env.token("SWW_ADMIN_TOKEN"),
|
||||||
MetricsToken: env.token("SWWAF_METRICS_TOKEN"),
|
MetricsToken: env.token("SWW_METRICS_TOKEN"),
|
||||||
MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"),
|
MetricsTopN: env.numberNotOff("SWW_METRICS_TOP_N", "50"),
|
||||||
RulesDir: env.value("SWWAF_RULES_DIR", "/etc/smallwebwaf/rules.d"),
|
RulesDir: env.value("SWW_RULES_DIR", "/etc/sww/rules.d"),
|
||||||
RulesEnabled: env.boolean("SWWAF_RULES_ENABLED", "true"),
|
RulesEnabled: env.boolean("SWW_RULES_ENABLED", "true"),
|
||||||
WAFMode: env.wafMode("SWWAF_WAF_MODE", WAFModeBlock),
|
WAFMode: env.wafMode("SWW_WAF_MODE", WAFModeBlock),
|
||||||
WAFParanoiaLevel: env.paranoiaLevel("SWWAF_WAF_PARANOIA_LEVEL", "1"),
|
WAFParanoiaLevel: env.paranoiaLevel("SWW_WAF_PARANOIA_LEVEL", "1"),
|
||||||
WAFAnomalyThreshold: env.numberOrOff("SWWAF_WAF_ANOMALY_THRESHOLD", "5"),
|
WAFAnomalyThreshold: env.numberOrOff("SWW_WAF_ANOMALY_THRESHOLD", "5"),
|
||||||
WAFDisabledRules: env.ruleIDs("SWWAF_WAF_DISABLED_RULES",
|
WAFDisabledRules: env.ruleIDs("SWW_WAF_DISABLED_RULES",
|
||||||
"920340,920420,920440,920640,930130,930140"),
|
"920340,920420,920440,920640,930130,930140"),
|
||||||
WAFExemptPaths: env.pathPrefixes("SWWAF_WAF_EXEMPT_PATHS", ""),
|
WAFExemptPaths: env.pathPrefixes("SWW_WAF_EXEMPT_PATHS", ""),
|
||||||
WAFBodyLimit: env.wafBodyLimit("SWWAF_WAF_BODY_LIMIT", off),
|
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off),
|
||||||
TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"),
|
CoreRuleSetExclusionsFile: env.filePath(
|
||||||
ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"),
|
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE"),
|
||||||
LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"),
|
TrapPaths: env.trapPaths("SWW_TRAP_PATHS"),
|
||||||
LogRemoteTLSCAs: env.certificates("SWWAF_LOG_REMOTE_TLS_CA_FILE"),
|
ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"),
|
||||||
LogRemoteBuffer: env.numberNotOff("SWWAF_LOG_REMOTE_BUFFER", "10000"),
|
LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"),
|
||||||
LogRemoteFacility: env.facility("SWWAF_LOG_REMOTE_FACILITY", "local0"),
|
LogRemoteTLSCAs: env.certificates("SWW_LOG_REMOTE_TLS_CA_FILE"),
|
||||||
AlertWebhookURL: env.webhookURL("SWWAF_ALERT_WEBHOOK_URL"),
|
LogRemoteBuffer: env.numberNotOff("SWW_LOG_REMOTE_BUFFER", "10000"),
|
||||||
AlertWebhookHeaders: env.webhookHeaders("SWWAF_ALERT_WEBHOOK_HEADERS"),
|
LogRemoteFacility: env.facility("SWW_LOG_REMOTE_FACILITY", "local0"),
|
||||||
AlertSlackWebhookURL: env.webhookURL("SWWAF_ALERT_SLACK_WEBHOOK_URL"),
|
AlertWebhookURL: env.webhookURL("SWW_ALERT_WEBHOOK_URL"),
|
||||||
AlertNtfyURL: env.webhookURL("SWWAF_ALERT_NTFY_URL"),
|
AlertWebhookHeaders: env.webhookHeaders("SWW_ALERT_WEBHOOK_HEADERS"),
|
||||||
AlertNtfyToken: env.secret("SWWAF_ALERT_NTFY_TOKEN"),
|
AlertSlackWebhookURL: env.webhookURL("SWW_ALERT_SLACK_WEBHOOK_URL"),
|
||||||
AlertEvents: env.alertEvents("SWWAF_ALERT_EVENTS",
|
AlertNtfyURL: env.webhookURL("SWW_ALERT_NTFY_URL"),
|
||||||
|
AlertNtfyToken: env.secret("SWW_ALERT_NTFY_TOKEN"),
|
||||||
|
AlertEvents: env.alertEvents("SWW_ALERT_EVENTS",
|
||||||
strings.Join(alerts.Events(), ",")),
|
strings.Join(alerts.Events(), ",")),
|
||||||
AlertCooldown: env.duration("SWWAF_ALERT_COOLDOWN", "15m"),
|
AlertCooldown: env.duration("SWW_ALERT_COOLDOWN", "15m"),
|
||||||
AlertMaxPerHour: env.numberOrOff("SWWAF_ALERT_MAX_PER_HOUR", "60"),
|
AlertMaxPerHour: env.numberOrOff("SWW_ALERT_MAX_PER_HOUR", "60"),
|
||||||
AnomalyClient: env.thresholds("SWWAF_ANOMALY_CLIENT_"),
|
AnomalyClient: env.thresholds("SWW_ANOMALY_CLIENT_"),
|
||||||
AnomalyNet: env.thresholds("SWWAF_ANOMALY_NET_"),
|
AnomalyNet: env.thresholds("SWW_ANOMALY_NET_"),
|
||||||
AnomalyASN: env.thresholds("SWWAF_ANOMALY_ASN_"),
|
AnomalyASN: env.thresholds("SWW_ANOMALY_ASN_"),
|
||||||
AnomalyTotal: env.thresholds("SWWAF_ANOMALY_TOTAL_"),
|
AnomalyTotal: env.thresholds("SWW_ANOMALY_TOTAL_"),
|
||||||
AnomalyWatch: env.thresholds("SWWAF_WATCH_"),
|
AnomalyWatch: env.thresholds("SWW_WATCH_"),
|
||||||
AnomalyNetV4Prefix: env.v4Prefix("SWWAF_ANOMALY_NET_V4_PREFIX", "24"),
|
AnomalyNetV4Prefix: env.v4Prefix("SWW_ANOMALY_NET_V4_PREFIX", "24"),
|
||||||
AnomalyNetV6Prefix: env.v6Prefix("SWWAF_ANOMALY_NET_V6_PREFIX", "48"),
|
AnomalyNetV6Prefix: env.v6Prefix("SWW_ANOMALY_NET_V6_PREFIX", "48"),
|
||||||
WatchNets: env.namedNetblocks("SWWAF_WATCH_NETS"),
|
WatchNets: env.namedNetblocks("SWW_WATCH_NETS"),
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.LogRemoteAppName = env.appName("SWWAF_LOG_REMOTE_APP_NAME",
|
cfg.LogRemoteAppName = env.appName("SWW_LOG_REMOTE_APP_NAME",
|
||||||
cfg.InstanceName, cfg.LogRemoteURL != nil)
|
cfg.InstanceName, cfg.LogRemoteURL != nil)
|
||||||
cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action(
|
cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action(
|
||||||
"SWWAF_BLOCKLIST_ACTION", "deny")
|
"SWW_BLOCKLIST_ACTION", "deny")
|
||||||
cfg.ReputationAction, cfg.ReputationLimitPercent = env.action(
|
cfg.ReputationAction, cfg.ReputationLimitPercent = env.action(
|
||||||
"SWWAF_REPUTATION_ACTION", "limit:25")
|
"SWW_REPUTATION_ACTION", "limit:25")
|
||||||
|
|
||||||
env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil)
|
env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil)
|
||||||
env.checkLookupDBPath(cfg)
|
env.checkLookupDBPath(cfg)
|
||||||
@@ -572,33 +577,33 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command reads only SMALLWEBWAF_COMMAND, the command the image runs and its
|
// Command reads only SWW_COMMAND, the command the image runs and its
|
||||||
// health check checks: rproxy when it is unset or empty, or servewaf. 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.
|
// be given as a file. Any other value is an error that names the setting.
|
||||||
func Command(lookupEnv func(string) (string, bool)) (string, error) {
|
func Command(lookupEnv func(string) (string, bool)) (string, error) {
|
||||||
command, _ := lookupEnv("SMALLWEBWAF_COMMAND")
|
command, _ := lookupEnv("SWW_COMMAND")
|
||||||
|
|
||||||
switch command {
|
switch command {
|
||||||
case "", "rproxy":
|
case "", "rproxy":
|
||||||
return "rproxy", nil
|
return "rproxy", nil
|
||||||
case "servewaf":
|
case "discriminator":
|
||||||
return command, nil
|
return command, nil
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("SMALLWEBWAF_COMMAND: %q %w", command, errNotCommand)
|
return "", fmt.Errorf("SWW_COMMAND: %q %w", command, errNotCommand)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeWAFListenAddr reads only SWWAF_LISTEN_ADDR, which may be given as a
|
// DiscriminatorListenAddr reads only SWW_LISTEN_ADDR, which may be given as a
|
||||||
// file, as FromEnvironment does. The health check in servewaf's container needs
|
// 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.
|
// no other setting, so it reads no other, nor a file that another names.
|
||||||
func ServeWAFListenAddr(lookupEnv func(string) (string, bool)) (string, error) {
|
func DiscriminatorListenAddr(lookupEnv func(string) (string, bool)) (string, error) {
|
||||||
env := &environment{lookupEnv: lookupEnv}
|
env := &environment{lookupEnv: lookupEnv}
|
||||||
listenAddr := env.address("SWWAF_LISTEN_ADDR")
|
listenAddr := env.address("SWW_LISTEN_ADDR")
|
||||||
|
|
||||||
return listenAddr, env.err
|
return listenAddr, env.err
|
||||||
}
|
}
|
||||||
|
|
||||||
// InstanceName reads only SWWAF_INSTANCE_NAME, which may be given as a
|
// InstanceName reads only SWW_INSTANCE_NAME, which may be given as a
|
||||||
// file, as FromEnvironment does, so that the line saying a setting is
|
// file, as FromEnvironment does, so that the line saying a setting is
|
||||||
// invalid carries it too. A file that cannot be read gives the default
|
// invalid carries it too. A file that cannot be read gives the default
|
||||||
// here, and FromEnvironment then stops the start over it.
|
// here, and FromEnvironment then stops the start over it.
|
||||||
@@ -956,30 +961,30 @@ func (e *environment) lookupSource(name, defaultValue string) string {
|
|||||||
return source
|
return source
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkLookupDBPath refuses SWWAF_LOOKUP_SOURCE=file without
|
// checkLookupDBPath refuses SWW_LOOKUP_SOURCE=file without
|
||||||
// SWWAF_LOOKUP_DB_PATH, and SWWAF_LOOKUP_DB_PATH with any other source:
|
// SWW_LOOKUP_DB_PATH, and SWW_LOOKUP_DB_PATH with any other source:
|
||||||
// one source at a time.
|
// one source at a time.
|
||||||
func (e *environment) checkLookupDBPath(cfg *Config) {
|
func (e *environment) checkLookupDBPath(cfg *Config) {
|
||||||
switch {
|
switch {
|
||||||
case cfg.LookupSource == fileSource && cfg.LookupDBPath == "":
|
case cfg.LookupSource == fileSource && cfg.LookupDBPath == "":
|
||||||
e.check("SWWAF_LOOKUP_SOURCE", fmt.Errorf(
|
e.check("SWW_LOOKUP_SOURCE", fmt.Errorf(
|
||||||
"is file while SWWAF_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath))
|
"is file while SWW_LOOKUP_DB_PATH is unset; %w", errNeedsDBPath))
|
||||||
case cfg.LookupSource != fileSource && cfg.LookupDBPath != "":
|
case cfg.LookupSource != fileSource && cfg.LookupDBPath != "":
|
||||||
e.check("SWWAF_LOOKUP_DB_PATH", fmt.Errorf(
|
e.check("SWW_LOOKUP_DB_PATH", fmt.Errorf(
|
||||||
"is set while SWWAF_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused))
|
"is set while SWW_LOOKUP_SOURCE is %s; %w", cfg.LookupSource, errDBPathUnused))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkCountriesAndLookups refuses a country on both country lists, and,
|
// checkCountriesAndLookups refuses a country on both country lists, and,
|
||||||
// while SWWAF_LOOKUP_SOURCE is off, each setting that needs clients looked
|
// while SWW_LOOKUP_SOURCE is off, each setting that needs clients looked
|
||||||
// up: the country lists, SWWAF_ADD_LOOKUP_HEADERS, the biased thresholds,
|
// up: the country lists, SWW_ADD_LOOKUP_HEADERS, the biased thresholds,
|
||||||
// SWWAF_ASN_LIMIT_PERCENT_URL among them, of which
|
// SWW_ASN_LIMIT_PERCENT_URL among them, of which
|
||||||
// SWWAF_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a
|
// SWW_UNKNOWN_LIMIT_PERCENT needs them only below 100, where it lowers a
|
||||||
// limit, and the anomaly thresholds per AS number.
|
// limit, and the anomaly thresholds per AS number.
|
||||||
func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
||||||
for _, country := range cfg.ExclusivelyAllowedCountries {
|
for _, country := range cfg.ExclusivelyAllowedCountries {
|
||||||
if slices.Contains(cfg.DeniedCountries, country) {
|
if slices.Contains(cfg.DeniedCountries, country) {
|
||||||
e.check("SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
e.check("SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
||||||
fmt.Errorf("%q %w", country, errOnBothLists))
|
fmt.Errorf("%q %w", country, errOnBothLists))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -992,52 +997,52 @@ func (e *environment) checkCountriesAndLookups(cfg *Config) {
|
|||||||
name string
|
name string
|
||||||
set bool
|
set bool
|
||||||
}{
|
}{
|
||||||
{"SWWAF_DENIED_COUNTRIES", len(cfg.DeniedCountries) > 0},
|
{"SWW_DENIED_COUNTRIES", len(cfg.DeniedCountries) > 0},
|
||||||
{"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
|
{"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES", len(cfg.ExclusivelyAllowedCountries) > 0},
|
||||||
{"SWWAF_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
|
{"SWW_ADD_LOOKUP_HEADERS", cfg.AddLookupHeaders},
|
||||||
{"SWWAF_ASN_LIMIT_PERCENT", len(cfg.ASNLimitPercent) > 0},
|
{"SWW_ASN_LIMIT_PERCENT", len(cfg.ASNLimitPercent) > 0},
|
||||||
{"SWWAF_COUNTRY_LIMIT_PERCENT", len(cfg.CountryLimitPercent) > 0},
|
{"SWW_COUNTRY_LIMIT_PERCENT", len(cfg.CountryLimitPercent) > 0},
|
||||||
{"SWWAF_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
|
{"SWW_ASN_BYTES_PERCENT", len(cfg.ASNBytesPercent) > 0},
|
||||||
{"SWWAF_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
|
{"SWW_COUNTRY_BYTES_PERCENT", len(cfg.CountryBytesPercent) > 0},
|
||||||
{"SWWAF_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
|
{"SWW_UNKNOWN_LIMIT_PERCENT", cfg.UnknownLimitPercent < 100},
|
||||||
{"SWWAF_ASN_LIMIT_PERCENT_URL", cfg.ASNLimitPercentURL != ""},
|
{"SWW_ASN_LIMIT_PERCENT_URL", cfg.ASNLimitPercentURL != ""},
|
||||||
{"SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0},
|
{"SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE", cfg.AnomalyASN.RequestsPerMinute > 0},
|
||||||
{"SWWAF_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0},
|
{"SWW_ANOMALY_ASN_REQUESTS_PER_HOUR", cfg.AnomalyASN.RequestsPerHour > 0},
|
||||||
{"SWWAF_ANOMALY_ASN_BYTES_PER_MINUTE", cfg.AnomalyASN.BytesPerMinute > 0},
|
{"SWW_ANOMALY_ASN_BYTES_PER_MINUTE", cfg.AnomalyASN.BytesPerMinute > 0},
|
||||||
{"SWWAF_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0},
|
{"SWW_ANOMALY_ASN_BYTES_PER_HOUR", cfg.AnomalyASN.BytesPerHour > 0},
|
||||||
} {
|
} {
|
||||||
if setting.set {
|
if setting.set {
|
||||||
e.check(setting.name, fmt.Errorf("is set while SWWAF_LOOKUP_SOURCE is off; %w",
|
e.check(setting.name, fmt.Errorf("is set while SWW_LOOKUP_SOURCE is off; %w",
|
||||||
errNeedsLookups))
|
errNeedsLookups))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkASNLimitPercentURL refuses SWWAF_ASN_LIMIT_PERCENT_URL naming a
|
// checkASNLimitPercentURL refuses SWW_ASN_LIMIT_PERCENT_URL naming a
|
||||||
// blocklist too: the file at a URL is fetched as one list or the other.
|
// blocklist too: the file at a URL is fetched as one list or the other.
|
||||||
func (e *environment) checkASNLimitPercentURL(cfg *Config) {
|
func (e *environment) checkASNLimitPercentURL(cfg *Config) {
|
||||||
if slices.Contains(cfg.BlocklistURLs, cfg.ASNLimitPercentURL) {
|
if slices.Contains(cfg.BlocklistURLs, cfg.ASNLimitPercentURL) {
|
||||||
e.check("SWWAF_ASN_LIMIT_PERCENT_URL",
|
e.check("SWW_ASN_LIMIT_PERCENT_URL",
|
||||||
fmt.Errorf("%q %w", cfg.ASNLimitPercentURL, errInBlocklistURLs))
|
fmt.Errorf("%q %w", cfg.ASNLimitPercentURL, errInBlocklistURLs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkCrowdSec refuses SWWAF_CROWDSEC_LAPI_URL without
|
// checkCrowdSec refuses SWW_CROWDSEC_LAPI_URL without
|
||||||
// SWWAF_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list
|
// SWW_CROWDSEC_LAPI_KEY, the key without the URL, and a decision list
|
||||||
// that is fetched as another list too.
|
// that is fetched as another list too.
|
||||||
func (e *environment) checkCrowdSec(cfg *Config) {
|
func (e *environment) checkCrowdSec(cfg *Config) {
|
||||||
decisionsURL := cfg.CrowdSecDecisionsURL
|
decisionsURL := cfg.CrowdSecDecisionsURL
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case decisionsURL != "" && cfg.CrowdSecKey == "":
|
case decisionsURL != "" && cfg.CrowdSecKey == "":
|
||||||
e.check("SWWAF_CROWDSEC_LAPI_URL", fmt.Errorf(
|
e.check("SWW_CROWDSEC_LAPI_URL", fmt.Errorf(
|
||||||
"is set while SWWAF_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey))
|
"is set while SWW_CROWDSEC_LAPI_KEY is unset; %w", errNeedsLAPIKey))
|
||||||
case decisionsURL == "" && cfg.CrowdSecKey != "":
|
case decisionsURL == "" && cfg.CrowdSecKey != "":
|
||||||
e.check("SWWAF_CROWDSEC_LAPI_KEY", fmt.Errorf(
|
e.check("SWW_CROWDSEC_LAPI_KEY", fmt.Errorf(
|
||||||
"is set while SWWAF_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused))
|
"is set while SWW_CROWDSEC_LAPI_URL is unset; %w", errLAPIKeyUnused))
|
||||||
case decisionsURL != "" && (slices.Contains(cfg.BlocklistURLs, decisionsURL) ||
|
case decisionsURL != "" && (slices.Contains(cfg.BlocklistURLs, decisionsURL) ||
|
||||||
decisionsURL == cfg.ASNLimitPercentURL):
|
decisionsURL == cfg.ASNLimitPercentURL):
|
||||||
e.check("SWWAF_CROWDSEC_LAPI_URL", fmt.Errorf("gives the decision list %q, which %w",
|
e.check("SWW_CROWDSEC_LAPI_URL", fmt.Errorf("gives the decision list %q, which %w",
|
||||||
decisionsURL, errAnotherList))
|
decisionsURL, errAnotherList))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1189,6 +1194,16 @@ func (e *environment) logRemoteURL(name string) *url.URL {
|
|||||||
return remote
|
return remote
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filePath reads a setting that is the path of a file, "" while it is
|
||||||
|
// unset. Its value names a file already, so, unlike the other settings,
|
||||||
|
// it has no _FILE form. Whoever uses the file reads it.
|
||||||
|
func (e *environment) filePath(name string) string {
|
||||||
|
path, _ := e.lookupEnv(name)
|
||||||
|
e.settings = append(e.settings, slog.String(name, path))
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
// certificates reads a setting that is the path of a file of PEM
|
// certificates reads a setting that is the path of a file of PEM
|
||||||
// certificates. Unset or empty, it is nil. Its value names a file
|
// certificates. Unset or empty, it is nil. Its value names a file
|
||||||
// already, so, unlike the other settings, it has no _FILE form.
|
// already, so, unlike the other settings, it has no _FILE form.
|
||||||
@@ -1226,14 +1241,14 @@ func (e *environment) facility(name, defaultValue string) int {
|
|||||||
return number
|
return number
|
||||||
}
|
}
|
||||||
|
|
||||||
// instanceName reads SWWAF_INSTANCE_NAME, by default the host's name. It
|
// instanceName reads SWW_INSTANCE_NAME, by default the host's name. It
|
||||||
// must be valid UTF-8: the metrics library panics on a label that is not.
|
// must be valid UTF-8: the metrics library panics on a label that is not.
|
||||||
func (e *environment) instanceName() string {
|
func (e *environment) instanceName() string {
|
||||||
hostname, _ := os.Hostname() // "" when the host has no name to give
|
hostname, _ := os.Hostname() // "" when the host has no name to give
|
||||||
|
|
||||||
value := e.value("SWWAF_INSTANCE_NAME", hostname)
|
value := e.value("SWW_INSTANCE_NAME", hostname)
|
||||||
if !utf8.ValidString(value) {
|
if !utf8.ValidString(value) {
|
||||||
e.check("SWWAF_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8))
|
e.check("SWW_INSTANCE_NAME", fmt.Errorf("%q %w", value, errNotUTF8))
|
||||||
}
|
}
|
||||||
|
|
||||||
return value
|
return value
|
||||||
@@ -1255,7 +1270,7 @@ func (e *environment) appName(name, instanceName string, sending bool) string {
|
|||||||
case set:
|
case set:
|
||||||
e.check(name, fmt.Errorf("%q %w", value, errNotAppName))
|
e.check(name, fmt.Errorf("%q %w", value, errNotAppName))
|
||||||
case sending:
|
case sending:
|
||||||
e.check(name, fmt.Errorf("is unset, and SWWAF_INSTANCE_NAME %q, its default, %w",
|
e.check(name, fmt.Errorf("is unset, and SWW_INSTANCE_NAME %q, its default, %w",
|
||||||
value, errNotAppName))
|
value, errNotAppName))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1263,19 +1278,19 @@ func (e *environment) appName(name, instanceName string, sending bool) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkInstanceNameForNtfy refuses an instance name that holds a control
|
// checkInstanceNameForNtfy refuses an instance name that holds a control
|
||||||
// character while ntfySet, SWWAF_ALERT_NTFY_URL being set: ntfy is sent
|
// character while ntfySet, SWW_ALERT_NTFY_URL being set: ntfy is sent
|
||||||
// the instance name in a header, which cannot hold one.
|
// the instance name in a header, which cannot hold one.
|
||||||
func (e *environment) checkInstanceNameForNtfy(instanceName string, ntfySet bool) {
|
func (e *environment) checkInstanceNameForNtfy(instanceName string, ntfySet bool) {
|
||||||
if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) {
|
if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) {
|
||||||
e.check("SWWAF_INSTANCE_NAME", fmt.Errorf(
|
e.check("SWW_INSTANCE_NAME", fmt.Errorf(
|
||||||
"%q %w, and is sent to ntfy in a header while SWWAF_ALERT_NTFY_URL is set",
|
"%q %w, and is sent to ntfy in a header while SWW_ALERT_NTFY_URL is set",
|
||||||
instanceName, errControlCharacter))
|
instanceName, errControlCharacter))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// webhookURL reads a setting that is a URL each alert is posted to:
|
// webhookURL reads a setting that is a URL each alert is posted to:
|
||||||
// SWWAF_ALERT_WEBHOOK_URL, SWWAF_ALERT_SLACK_WEBHOOK_URL or
|
// SWW_ALERT_WEBHOOK_URL, SWW_ALERT_SLACK_WEBHOOK_URL or
|
||||||
// SWWAF_ALERT_NTFY_URL. Unset or empty, it is nil, and no alert is posted
|
// SWW_ALERT_NTFY_URL. Unset or empty, it is nil, and no alert is posted
|
||||||
// there. The log shows ******** in place of its path and query, and an
|
// there. The log shows ******** in place of its path and query, and an
|
||||||
// error shows none of it, since a webhook or an ntfy topic can carry its
|
// error shows none of it, since a webhook or an ntfy topic can carry its
|
||||||
// secret there.
|
// secret there.
|
||||||
@@ -1395,7 +1410,7 @@ func parseSize(value string) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseWAFBodyLimit reads the most of a request body the Core Rule Set
|
// 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 servewaf
|
// reads: a size as parseSize reads it, or off, but at most 64K, since discriminator
|
||||||
// gets no more of a body than that.
|
// gets no more of a body than that.
|
||||||
func parseWAFBodyLimit(value string) (int64, error) {
|
func parseWAFBodyLimit(value string) (int64, error) {
|
||||||
limit, err := parseSize(value)
|
limit, err := parseSize(value)
|
||||||
@@ -1724,7 +1739,7 @@ func parseCountry(value string) (string, error) {
|
|||||||
|
|
||||||
// ParseASN reads an AS number such as AS64496, in either case, and
|
// ParseASN reads an AS number such as AS64496, in either case, and
|
||||||
// returns it as the lookup gives it: AS and the number, in capitals and
|
// returns it as the lookup gives it: AS and the number, in capitals and
|
||||||
// without leading zeros. The file SWWAF_ASN_LIMIT_PERCENT_URL names is
|
// without leading zeros. The file SWW_ASN_LIMIT_PERCENT_URL names is
|
||||||
// read with it too.
|
// read with it too.
|
||||||
func ParseASN(value string) (string, error) {
|
func ParseASN(value string) (string, error) {
|
||||||
digits, hasAS := strings.CutPrefix(strings.ToUpper(value), "AS")
|
digits, hasAS := strings.CutPrefix(strings.ToUpper(value), "AS")
|
||||||
@@ -1778,7 +1793,7 @@ func parsePercents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ParsePercent reads a percentage, a whole number from 0 to 100. The file
|
// ParsePercent reads a percentage, a whole number from 0 to 100. The file
|
||||||
// SWWAF_ASN_LIMIT_PERCENT_URL names is read with it too.
|
// SWW_ASN_LIMIT_PERCENT_URL names is read with it too.
|
||||||
func ParsePercent(value string) (int64, error) {
|
func ParsePercent(value string) (int64, error) {
|
||||||
percent, err := strconv.ParseInt(value, 10, 64)
|
percent, err := strconv.ParseInt(value, 10, 64)
|
||||||
if err != nil || percent < 0 || percent > 100 {
|
if err != nil || percent < 0 || percent > 100 {
|
||||||
@@ -1998,7 +2013,7 @@ const (
|
|||||||
maxLabelLength = 63
|
maxLabelLength = 63
|
||||||
// labelChars are the characters a label of a DNS zone may hold.
|
// labelChars are the characters a label of a DNS zone may hold.
|
||||||
labelChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"
|
labelChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"
|
||||||
// dnsPort is the port a resolver is asked on when SWWAF_DNSBL_RESOLVER
|
// dnsPort is the port a resolver is asked on when SWW_DNSBL_RESOLVER
|
||||||
// gives none.
|
// gives none.
|
||||||
dnsPort = 53
|
dnsPort = 53
|
||||||
)
|
)
|
||||||
|
|||||||
+122
-117
@@ -23,98 +23,99 @@ import (
|
|||||||
|
|
||||||
// The settings, by name.
|
// The settings, by name.
|
||||||
const (
|
const (
|
||||||
listenAddr = "SWWAF_LISTEN_ADDR"
|
listenAddr = "SWW_LISTEN_ADDR"
|
||||||
mode = "SWWAF_MODE"
|
mode = "SWW_MODE"
|
||||||
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
|
ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX"
|
||||||
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
|
maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS"
|
||||||
allowNets = "SWWAF_ALLOW_NETS"
|
allowNets = "SWW_ALLOW_NETS"
|
||||||
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
|
||||||
denyNets = "SWWAF_DENY_NETS"
|
denyNets = "SWW_DENY_NETS"
|
||||||
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE"
|
||||||
rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR"
|
rateLimitPerHour = "SWW_RATE_LIMIT_PER_HOUR"
|
||||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
|
||||||
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS"
|
||||||
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE"
|
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
|
||||||
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR"
|
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
|
||||||
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY"
|
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
|
||||||
bytesCount = "SWWAF_BYTES_COUNT"
|
bytesCount = "SWW_BYTES_COUNT"
|
||||||
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
lookupSource = "SWW_LOOKUP_SOURCE"
|
||||||
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
|
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
||||||
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
||||||
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS"
|
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
||||||
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
deniedCountries = "SWW_DENIED_COUNTRIES"
|
||||||
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||||
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT"
|
asnLimitPercent = "SWW_ASN_LIMIT_PERCENT"
|
||||||
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT"
|
countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT"
|
||||||
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT"
|
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
||||||
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT"
|
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
||||||
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT"
|
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
||||||
asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL"
|
asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL"
|
||||||
blocklistURLs = "SWWAF_BLOCKLIST_URLS"
|
blocklistURLs = "SWW_BLOCKLIST_URLS"
|
||||||
blocklistRefresh = "SWWAF_BLOCKLIST_REFRESH"
|
blocklistRefresh = "SWW_BLOCKLIST_REFRESH"
|
||||||
blocklistAction = "SWWAF_BLOCKLIST_ACTION"
|
blocklistAction = "SWW_BLOCKLIST_ACTION"
|
||||||
dnsblZones = "SWWAF_DNSBL_ZONES"
|
dnsblZones = "SWW_DNSBL_ZONES"
|
||||||
dnsblResolver = "SWWAF_DNSBL_RESOLVER"
|
dnsblResolver = "SWW_DNSBL_RESOLVER"
|
||||||
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY"
|
abuseIPDBKey = "SWW_ABUSEIPDB_KEY"
|
||||||
abuseIPDBMinScore = "SWWAF_ABUSEIPDB_MIN_SCORE"
|
abuseIPDBMinScore = "SWW_ABUSEIPDB_MIN_SCORE"
|
||||||
abuseIPDBDailyBudget = "SWWAF_ABUSEIPDB_DAILY_BUDGET"
|
abuseIPDBDailyBudget = "SWW_ABUSEIPDB_DAILY_BUDGET"
|
||||||
reputationAction = "SWWAF_REPUTATION_ACTION"
|
reputationAction = "SWW_REPUTATION_ACTION"
|
||||||
reputationCacheTTL = "SWWAF_REPUTATION_CACHE_TTL"
|
reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL"
|
||||||
reputationTimeout = "SWWAF_REPUTATION_TIMEOUT"
|
reputationTimeout = "SWW_REPUTATION_TIMEOUT"
|
||||||
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL"
|
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
|
||||||
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY"
|
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
|
||||||
banResponse = "SWWAF_BAN_RESPONSE"
|
banResponse = "SWW_BAN_RESPONSE"
|
||||||
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
|
||||||
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
|
||||||
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
maxBanDuration = "SWW_MAX_BAN_DURATION"
|
||||||
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
attackBanDuration = "SWW_ATTACK_BAN_DURATION"
|
||||||
maxBans = "SWWAF_MAX_BANS"
|
maxBans = "SWW_MAX_BANS"
|
||||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
|
||||||
stateDir = "SWWAF_STATE_DIR"
|
stateDir = "SWW_STATE_DIR"
|
||||||
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY"
|
stateWriteDelay = "SWW_STATE_WRITE_DELAY"
|
||||||
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
|
stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL"
|
||||||
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||||
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||||
metricsTopN = "SWWAF_METRICS_TOP_N"
|
metricsTopN = "SWW_METRICS_TOP_N"
|
||||||
instanceName = "SWWAF_INSTANCE_NAME"
|
instanceName = "SWW_INSTANCE_NAME"
|
||||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
|
||||||
logLevel = "SWWAF_LOG_LEVEL"
|
logLevel = "SWW_LOG_LEVEL"
|
||||||
rulesDir = "SWWAF_RULES_DIR"
|
rulesDir = "SWW_RULES_DIR"
|
||||||
rulesEnabled = "SWWAF_RULES_ENABLED"
|
rulesEnabled = "SWW_RULES_ENABLED"
|
||||||
wafMode = "SWWAF_WAF_MODE"
|
wafMode = "SWW_WAF_MODE"
|
||||||
wafParanoiaLevel = "SWWAF_WAF_PARANOIA_LEVEL"
|
wafParanoiaLevel = "SWW_WAF_PARANOIA_LEVEL"
|
||||||
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD"
|
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
|
||||||
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
|
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
||||||
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
|
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||||
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
|
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||||
trapPaths = "SWWAF_TRAP_PATHS"
|
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
|
||||||
errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
|
trapPaths = "SWW_TRAP_PATHS"
|
||||||
logRemoteURL = "SWWAF_LOG_REMOTE_URL"
|
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
||||||
logRemoteTLSCAFile = "SWWAF_LOG_REMOTE_TLS_CA_FILE"
|
logRemoteURL = "SWW_LOG_REMOTE_URL"
|
||||||
logRemoteBuffer = "SWWAF_LOG_REMOTE_BUFFER"
|
logRemoteTLSCAFile = "SWW_LOG_REMOTE_TLS_CA_FILE"
|
||||||
logRemoteFacility = "SWWAF_LOG_REMOTE_FACILITY"
|
logRemoteBuffer = "SWW_LOG_REMOTE_BUFFER"
|
||||||
logRemoteAppName = "SWWAF_LOG_REMOTE_APP_NAME"
|
logRemoteFacility = "SWW_LOG_REMOTE_FACILITY"
|
||||||
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL"
|
logRemoteAppName = "SWW_LOG_REMOTE_APP_NAME"
|
||||||
alertWebhookHeaders = "SWWAF_ALERT_WEBHOOK_HEADERS"
|
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
|
||||||
alertSlackWebhookURL = "SWWAF_ALERT_SLACK_WEBHOOK_URL"
|
alertWebhookHeaders = "SWW_ALERT_WEBHOOK_HEADERS"
|
||||||
alertNtfyURL = "SWWAF_ALERT_NTFY_URL"
|
alertSlackWebhookURL = "SWW_ALERT_SLACK_WEBHOOK_URL"
|
||||||
alertNtfyToken = "SWWAF_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
|
alertNtfyURL = "SWW_ALERT_NTFY_URL"
|
||||||
alertEvents = "SWWAF_ALERT_EVENTS"
|
alertNtfyToken = "SWW_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
|
||||||
alertCooldown = "SWWAF_ALERT_COOLDOWN"
|
alertEvents = "SWW_ALERT_EVENTS"
|
||||||
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
|
alertCooldown = "SWW_ALERT_COOLDOWN"
|
||||||
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX"
|
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
|
||||||
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX"
|
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
|
||||||
watchNets = "SWWAF_WATCH_NETS"
|
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
|
||||||
|
watchNets = "SWW_WATCH_NETS"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The anomaly thresholds: each of the prefixes below, which name a scope,
|
// The anomaly thresholds: each of the prefixes below, which name a scope,
|
||||||
// followed by each of the four ends.
|
// followed by each of the four ends.
|
||||||
const (
|
const (
|
||||||
anomalyClient = "SWWAF_ANOMALY_CLIENT_"
|
anomalyClient = "SWW_ANOMALY_CLIENT_"
|
||||||
anomalyNet = "SWWAF_ANOMALY_NET_"
|
anomalyNet = "SWW_ANOMALY_NET_"
|
||||||
anomalyASN = "SWWAF_ANOMALY_ASN_"
|
anomalyASN = "SWW_ANOMALY_ASN_"
|
||||||
anomalyTotal = "SWWAF_ANOMALY_TOTAL_"
|
anomalyTotal = "SWW_ANOMALY_TOTAL_"
|
||||||
watch = "SWWAF_WATCH_"
|
watch = "SWW_WATCH_"
|
||||||
|
|
||||||
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
||||||
requestsPerHour = "REQUESTS_PER_HOUR"
|
requestsPerHour = "REQUESTS_PER_HOUR"
|
||||||
@@ -155,21 +156,21 @@ func loggedAnomalyDefaults() map[string]string {
|
|||||||
return logged
|
return logged
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultAlertEvents is the default of SWWAF_ALERT_EVENTS, and
|
// defaultAlertEvents is the default of SWW_ALERT_EVENTS, and
|
||||||
// defaultAlertCooldown that of SWWAF_ALERT_COOLDOWN.
|
// defaultAlertCooldown that of SWW_ALERT_COOLDOWN.
|
||||||
const (
|
const (
|
||||||
defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," +
|
defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," +
|
||||||
"source_failure,file_error"
|
"source_failure,file_error"
|
||||||
defaultAlertCooldown = "15m"
|
defaultAlertCooldown = "15m"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultReputationCacheTTL is the default of SWWAF_REPUTATION_CACHE_TTL.
|
// defaultReputationCacheTTL is the default of SWW_REPUTATION_CACHE_TTL.
|
||||||
const defaultReputationCacheTTL = "24h"
|
const defaultReputationCacheTTL = "24h"
|
||||||
|
|
||||||
// defaultWAFDisabledRules is the default of SWWAF_WAF_DISABLED_RULES.
|
// defaultWAFDisabledRules is the default of SWW_WAF_DISABLED_RULES.
|
||||||
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
|
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
|
||||||
|
|
||||||
// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS.
|
// defaultLogRequestHeaders is the default of SWW_LOG_REQUEST_HEADERS.
|
||||||
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
|
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
|
||||||
"content-type,origin,range"
|
"content-type,origin,range"
|
||||||
|
|
||||||
@@ -194,8 +195,8 @@ const (
|
|||||||
otherToken = "fedcba9876543210fedcba9876543210"
|
otherToken = "fedcba9876543210fedcba9876543210"
|
||||||
)
|
)
|
||||||
|
|
||||||
// instance is an SWWAF_INSTANCE_NAME that is a valid app name too, and
|
// instance is an SWW_INSTANCE_NAME that is a valid app name too, and
|
||||||
// remoteURL an SWWAF_LOG_REMOTE_URL, for the tests that send the lines.
|
// remoteURL an SWW_LOG_REMOTE_URL, for the tests that send the lines.
|
||||||
const (
|
const (
|
||||||
instance = "fsn1app1/blog"
|
instance = "fsn1app1/blog"
|
||||||
remoteURL = "syslog+udp://192.0.2.1:514"
|
remoteURL = "syslog+udp://192.0.2.1:514"
|
||||||
@@ -207,7 +208,7 @@ const off = "off"
|
|||||||
// enabled is true, as a setting's value.
|
// enabled is true, as a setting's value.
|
||||||
const enabled = "true"
|
const enabled = "true"
|
||||||
|
|
||||||
// defaultLookupSource is the default of SWWAF_LOOKUP_SOURCE, and
|
// defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and
|
||||||
// fileSource the source that is the lookup database.
|
// fileSource the source that is the lookup database.
|
||||||
const (
|
const (
|
||||||
defaultLookupSource = "geojs"
|
defaultLookupSource = "geojs"
|
||||||
@@ -254,12 +255,12 @@ func TestDefaults(t *testing.T) {
|
|||||||
AttackBanDuration: 7 * 24 * time.Hour,
|
AttackBanDuration: 7 * 24 * time.Hour,
|
||||||
MaxBans: 5000,
|
MaxBans: 5000,
|
||||||
BanScopeV4Prefix: 32,
|
BanScopeV4Prefix: 32,
|
||||||
StateDir: "/var/lib/smallwebwaf",
|
StateDir: "/var/lib/sww",
|
||||||
StateWriteDelay: 10 * time.Second,
|
StateWriteDelay: 10 * time.Second,
|
||||||
StateCounterInterval: 15 * time.Minute,
|
StateCounterInterval: 15 * time.Minute,
|
||||||
MetricsToken: "",
|
MetricsToken: "",
|
||||||
MetricsTopN: 50,
|
MetricsTopN: 50,
|
||||||
RulesDir: "/etc/smallwebwaf/rules.d",
|
RulesDir: "/etc/sww/rules.d",
|
||||||
RulesEnabled: true,
|
RulesEnabled: true,
|
||||||
})
|
})
|
||||||
wantLookupSettings(t, cfg, config.Config{
|
wantLookupSettings(t, cfg, config.Config{
|
||||||
@@ -539,13 +540,14 @@ func TestCoreRuleSetSettings(t *testing.T) {
|
|||||||
environment{
|
environment{
|
||||||
wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10",
|
wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10",
|
||||||
wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/",
|
wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/",
|
||||||
wafBodyLimit: "32K",
|
wafBodyLimit: "32K", exclusionsFile: "/etc/sww/crs-exclusions.conf",
|
||||||
},
|
},
|
||||||
config.Config{
|
config.Config{
|
||||||
WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10,
|
WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10,
|
||||||
WAFDisabledRules: []int{942100, 920350},
|
WAFDisabledRules: []int{942100, 920350},
|
||||||
WAFExemptPaths: []string{"/api/", "/static/"},
|
WAFExemptPaths: []string{"/api/", "/static/"},
|
||||||
WAFBodyLimit: 32 << 10,
|
WAFBodyLimit: 32 << 10,
|
||||||
|
CoreRuleSetExclusionsFile: "/etc/sww/crs-exclusions.conf",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -566,6 +568,7 @@ func TestCoreRuleSetSettings(t *testing.T) {
|
|||||||
WAFAnomalyThreshold: cfg.WAFAnomalyThreshold,
|
WAFAnomalyThreshold: cfg.WAFAnomalyThreshold,
|
||||||
WAFDisabledRules: cfg.WAFDisabledRules, WAFExemptPaths: cfg.WAFExemptPaths,
|
WAFDisabledRules: cfg.WAFDisabledRules, WAFExemptPaths: cfg.WAFExemptPaths,
|
||||||
WAFBodyLimit: cfg.WAFBodyLimit,
|
WAFBodyLimit: cfg.WAFBodyLimit,
|
||||||
|
CoreRuleSetExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(got, tc.want) {
|
if !reflect.DeepEqual(got, tc.want) {
|
||||||
t.Errorf("%v gave\n%+v\nwant\n%+v", tc.env, got, tc.want)
|
t.Errorf("%v gave\n%+v\nwant\n%+v", tc.env, got, tc.want)
|
||||||
@@ -588,7 +591,7 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
|
|||||||
const (
|
const (
|
||||||
notParanoiaLevel = " is not a paranoia level, from 1 to 4"
|
notParanoiaLevel = " is not a paranoia level, from 1 to 4"
|
||||||
setupRule = " is from 900000 to 900999, the ids of the rules that set " +
|
setupRule = " is from 900000 to 900999, the ids of the rules that set " +
|
||||||
"the Core Rule Set up and of smallwebwaf's own, which cannot be switched off"
|
"the Core Rule Set up and of sww's own, which cannot be switched off"
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct{ name, value, want string }{
|
for _, tc := range []struct{ name, value, want string }{
|
||||||
@@ -627,10 +630,11 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
|
|||||||
wafBodyLimit, "128KB",
|
wafBodyLimit, "128KB",
|
||||||
`"128KB" is not a size such as 512K, 100M or 5G, or off`,
|
`"128KB" is not a size such as 512K, 100M or 5G, or off`,
|
||||||
},
|
},
|
||||||
{wafBodyLimit, "65K", `"65K" is more than 64K, the most servewaf gets of a body`},
|
{wafBodyLimit, "65K", `"65K" is more than 64K, the most discriminator ` +
|
||||||
|
`gets of a body`},
|
||||||
{
|
{
|
||||||
wafBodyLimit, "65537",
|
wafBodyLimit, "65537",
|
||||||
`"65537" is more than 64K, the most servewaf 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) {
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||||
@@ -856,7 +860,7 @@ func TestAlertSettingsAsSet(t *testing.T) {
|
|||||||
const (
|
const (
|
||||||
webhook = "https://alerts.example:8443/hooks/waf?team=ops"
|
webhook = "https://alerts.example:8443/hooks/waf?team=ops"
|
||||||
slack = "https://hooks.slack.example/services/T0123/B4567/abcdef"
|
slack = "https://hooks.slack.example/services/T0123/B4567/abcdef"
|
||||||
ntfy = "https://ntfy.example/smallwebwaf-alerts"
|
ntfy = "https://ntfy.example/sww-alerts"
|
||||||
token = "tk_0123456789abcdefghijklmnopq"
|
token = "tk_0123456789abcdefghijklmnopq"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -904,17 +908,17 @@ func TestInvalidAlertSettingStopsTheStart(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
wantStartStopped(t, []struct{ name, value string }{
|
wantStartStopped(t, []struct{ name, value string }{
|
||||||
{alertWebhookURL, "alerts.example/smallwebwaf"},
|
{alertWebhookURL, "alerts.example/sww"},
|
||||||
{alertWebhookURL, "ftp://alerts.example/"},
|
{alertWebhookURL, "ftp://alerts.example/"},
|
||||||
{alertWebhookURL, "https:///smallwebwaf"},
|
{alertWebhookURL, "https:///sww"},
|
||||||
{alertWebhookURL, "https://user:password@alerts.example/"},
|
{alertWebhookURL, "https://user:password@alerts.example/"},
|
||||||
{alertWebhookURL, "https://alerts.example/#top"},
|
{alertWebhookURL, "https://alerts.example/#top"},
|
||||||
{alertWebhookURL, "https://alerts.example:0/"},
|
{alertWebhookURL, "https://alerts.example:0/"},
|
||||||
{alertWebhookURL, "https://alerts.example:65536/"},
|
{alertWebhookURL, "https://alerts.example:65536/"},
|
||||||
{alertSlackWebhookURL, "hooks.slack.example/services/T0123"},
|
{alertSlackWebhookURL, "hooks.slack.example/services/T0123"},
|
||||||
{alertSlackWebhookURL, "https://user:password@hooks.slack.example/"},
|
{alertSlackWebhookURL, "https://user:password@hooks.slack.example/"},
|
||||||
{alertNtfyURL, "ntfy://ntfy.example/smallwebwaf-alerts"},
|
{alertNtfyURL, "ntfy://ntfy.example/sww-alerts"},
|
||||||
{alertNtfyURL, "https://ntfy.example/smallwebwaf-alerts#top"},
|
{alertNtfyURL, "https://ntfy.example/sww-alerts#top"},
|
||||||
{alertWebhookHeaders, "Authorization"},
|
{alertWebhookHeaders, "Authorization"},
|
||||||
{alertWebhookHeaders, "X Team:ops"},
|
{alertWebhookHeaders, "X Team:ops"},
|
||||||
{alertWebhookHeaders, ":ops"},
|
{alertWebhookHeaders, ":ops"},
|
||||||
@@ -994,7 +998,7 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) {
|
|||||||
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv)
|
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv)
|
||||||
|
|
||||||
want := alertWebhookURL + ": is not an http or https URL without a user or " +
|
want := alertWebhookURL + ": is not an http or https URL without a user or " +
|
||||||
"a fragment, such as https://alerts.example/smallwebwaf"
|
"a fragment, such as https://alerts.example/sww"
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
t.Errorf("error %v, want %s", err, want)
|
t.Errorf("error %v, want %s", err, want)
|
||||||
}
|
}
|
||||||
@@ -1008,7 +1012,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
|
|||||||
|
|
||||||
cfg := fromEnvironment(t, environment{
|
cfg := fromEnvironment(t, environment{
|
||||||
alertSlackWebhookURL: "https://hooks.slack.example/services/T0123/B4567/abcdef",
|
alertSlackWebhookURL: "https://hooks.slack.example/services/T0123/B4567/abcdef",
|
||||||
alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts",
|
alertNtfyURL: "https://ntfy.example/sww-alerts",
|
||||||
alertNtfyToken: token,
|
alertNtfyToken: token,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1027,7 +1031,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, secret := range []string{"T0123", "smallwebwaf-alerts", token} {
|
for _, secret := range []string{"T0123", "sww-alerts", token} {
|
||||||
if strings.Contains(logged, secret) {
|
if strings.Contains(logged, secret) {
|
||||||
t.Errorf("%s in the settings logged: %s", secret, logged)
|
t.Errorf("%s in the settings logged: %s", secret, logged)
|
||||||
}
|
}
|
||||||
@@ -1058,7 +1062,7 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testin
|
|||||||
const name = "fsn1app1\r"
|
const name = "fsn1app1\r"
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
instanceName: name, alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts",
|
instanceName: name, alertNtfyURL: "https://ntfy.example/sww-alerts",
|
||||||
}.lookupEnv)
|
}.lookupEnv)
|
||||||
|
|
||||||
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
|
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
|
||||||
@@ -1200,7 +1204,7 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set empty, the lists need nothing looked up, and nor does
|
// Set empty, the lists need nothing looked up, and nor does
|
||||||
// SWWAF_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly
|
// SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly
|
||||||
// threshold per AS number that is off, any other anomaly threshold, or
|
// threshold per AS number that is off, any other anomaly threshold, or
|
||||||
// a blocklist.
|
// a blocklist.
|
||||||
env := environment{
|
env := environment{
|
||||||
@@ -1438,7 +1442,7 @@ const (
|
|||||||
asnURL = "https://lists.example/asn.txt"
|
asnURL = "https://lists.example/asn.txt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The actions of SWWAF_BLOCKLIST_ACTION, as Config gives them.
|
// The actions of SWW_BLOCKLIST_ACTION, as Config gives them.
|
||||||
const (
|
const (
|
||||||
actionDeny = "deny"
|
actionDeny = "deny"
|
||||||
actionLimit = "limit"
|
actionLimit = "limit"
|
||||||
@@ -1534,7 +1538,7 @@ func TestASNLimitPercentURLThatIsABlocklistStopsTheStart(t *testing.T) {
|
|||||||
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
|
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
|
||||||
}.lookupEnv)
|
}.lookupEnv)
|
||||||
|
|
||||||
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWWAF_BLOCKLIST_URLS too`
|
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_BLOCKLIST_URLS too`
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
t.Errorf("error %v, want %s", err, want)
|
t.Errorf("error %v, want %s", err, want)
|
||||||
}
|
}
|
||||||
@@ -1837,7 +1841,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
notLAPIURL = " is not an http or https URL without a user or a fragment, " +
|
notLAPIURL = " is not an http or https URL without a user or a fragment, " +
|
||||||
"such as http://172.17.0.1:8080"
|
"such as http://172.17.0.1:8080"
|
||||||
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
|
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
|
||||||
`SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too`
|
`SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too`
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -2039,7 +2043,7 @@ func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
|
|||||||
{maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off},
|
{maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off},
|
||||||
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
|
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
|
||||||
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
|
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
|
||||||
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/smallwebwaf"},
|
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/sww"},
|
||||||
{stateWriteDelay, off}, {stateWriteDelay, "0s"},
|
{stateWriteDelay, off}, {stateWriteDelay, "0s"},
|
||||||
{stateCounterInterval, off}, {stateCounterInterval, "15"},
|
{stateCounterInterval, off}, {stateCounterInterval, "15"},
|
||||||
})
|
})
|
||||||
@@ -2310,7 +2314,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
attackBanDuration: "7d",
|
attackBanDuration: "7d",
|
||||||
maxBans: "5000",
|
maxBans: "5000",
|
||||||
banScopeV4Prefix: "32",
|
banScopeV4Prefix: "32",
|
||||||
stateDir: "/var/lib/smallwebwaf",
|
stateDir: "/var/lib/sww",
|
||||||
stateWriteDelay: "10s",
|
stateWriteDelay: "10s",
|
||||||
stateCounterInterval: "15m",
|
stateCounterInterval: "15m",
|
||||||
adminToken: "",
|
adminToken: "",
|
||||||
@@ -2319,7 +2323,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
instanceName: hostname,
|
instanceName: hostname,
|
||||||
logRequestHeaders: defaultLogRequestHeaders,
|
logRequestHeaders: defaultLogRequestHeaders,
|
||||||
logLevel: "info",
|
logLevel: "info",
|
||||||
rulesDir: "/etc/smallwebwaf/rules.d",
|
rulesDir: "/etc/sww/rules.d",
|
||||||
rulesEnabled: "true",
|
rulesEnabled: "true",
|
||||||
wafMode: config.WAFModeBlock,
|
wafMode: config.WAFModeBlock,
|
||||||
wafParanoiaLevel: "1",
|
wafParanoiaLevel: "1",
|
||||||
@@ -2327,6 +2331,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
wafDisabledRules: defaultWAFDisabledRules,
|
wafDisabledRules: defaultWAFDisabledRules,
|
||||||
wafExemptPaths: "",
|
wafExemptPaths: "",
|
||||||
wafBodyLimit: off,
|
wafBodyLimit: off,
|
||||||
|
exclusionsFile: "",
|
||||||
trapPaths: "",
|
trapPaths: "",
|
||||||
errorBurstThreshold: "30",
|
errorBurstThreshold: "30",
|
||||||
logRemoteURL: "",
|
logRemoteURL: "",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RProxy is the settings of `smallwebwaf rproxy`. A size or time limit of zero
|
// RProxy is the settings of `sww rproxy`. A size or time limit of zero
|
||||||
// is off.
|
// is off.
|
||||||
type RProxy struct {
|
type RProxy struct {
|
||||||
// ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR).
|
// ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR).
|
||||||
@@ -22,12 +22,12 @@ type RProxy struct {
|
|||||||
// TrustedProxies are the netblocks whose X-Forwarded-For is believed
|
// TrustedProxies are the netblocks whose X-Forwarded-For is believed
|
||||||
// (SWRP_TRUSTED_PROXIES).
|
// (SWRP_TRUSTED_PROXIES).
|
||||||
TrustedProxies []netip.Prefix
|
TrustedProxies []netip.Prefix
|
||||||
// WAFBackend is servewaf's URL (SWRP_WAF_BACKEND).
|
// WAFBackend is discriminator's URL (SWRP_WAF_BACKEND).
|
||||||
WAFBackend *url.URL
|
WAFBackend *url.URL
|
||||||
// Timeout is how long rproxy waits for servewaf'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.
|
// and to a report (SWRP_TIMEOUT_MS). It cannot be off.
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// DefaultDisposition is what is done with a request when servewaf 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:
|
// connected to, does not answer at all, or does not answer within Timeout:
|
||||||
// allow or deny (SWRP_DEFAULT_DISPOSITION).
|
// allow or deny (SWRP_DEFAULT_DISPOSITION).
|
||||||
DefaultDisposition string
|
DefaultDisposition string
|
||||||
@@ -50,7 +50,7 @@ type RProxy struct {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
errNeedsWAFBackend = errors.New(
|
errNeedsWAFBackend = errors.New(
|
||||||
"is required: it is servewaf's URL, such as http://servewaf:8080")
|
"is required: it is discriminator's URL, such as http://discriminator:8080")
|
||||||
errNotDisposition = errors.New("is not allow or deny")
|
errNotDisposition = errors.New("is not allow or deny")
|
||||||
errNotMilliseconds = errors.New(
|
errNotMilliseconds = errors.New(
|
||||||
"is not a whole number of milliseconds above zero, such as 2000")
|
"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...)
|
return slog.GroupValue(c.settings...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// wafBackend reads the setting that is servewaf'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 {
|
func (e *environment) wafBackend(name string) *url.URL {
|
||||||
value := e.value(name, "")
|
value := e.value(name, "")
|
||||||
if value == "" {
|
if value == "" {
|
||||||
@@ -139,7 +139,7 @@ func (e *environment) wafBackend(name string) *url.URL {
|
|||||||
return backend
|
return backend
|
||||||
}
|
}
|
||||||
|
|
||||||
// disposition reads the setting that is what is done with a request when servewaf
|
// 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:
|
// cannot be connected to, does not answer at all, or does not answer in time:
|
||||||
// allow or deny.
|
// allow or deny.
|
||||||
func (e *environment) disposition(name, defaultValue string) string {
|
func (e *environment) disposition(name, defaultValue string) string {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const (
|
|||||||
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE"
|
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE"
|
||||||
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
|
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
|
||||||
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS"
|
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS"
|
||||||
backendURL = "http://servewaf:8080"
|
backendURL = "http://discriminator:8080"
|
||||||
rproxyDefaultAddr = ":8080"
|
rproxyDefaultAddr = ":8080"
|
||||||
rproxyDefaultApp = "http://127.0.0.1:8081"
|
rproxyDefaultApp = "http://127.0.0.1:8081"
|
||||||
notASize = "a lot"
|
notASize = "a lot"
|
||||||
@@ -114,8 +114,8 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
|
|||||||
name, value string
|
name, value string
|
||||||
}{
|
}{
|
||||||
{wafBackend, ""},
|
{wafBackend, ""},
|
||||||
{wafBackend, "servewaf:8080"},
|
{wafBackend, "discriminator:8080"},
|
||||||
{wafBackend, "http://servewaf:8080/path"},
|
{wafBackend, "http://discriminator:8080/path"},
|
||||||
{rproxyTimeout, "0"},
|
{rproxyTimeout, "0"},
|
||||||
{rproxyTimeout, "-1"},
|
{rproxyTimeout, "-1"},
|
||||||
{rproxyTimeout, off},
|
{rproxyTimeout, off},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -49,9 +49,9 @@ func (rq *request) answerHealth() {
|
|||||||
_, _ = io.WriteString(rq.out, "ok\n")
|
_, _ = io.WriteString(rq.out, "ok\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// answerAdmin answers a request for one of servewaf'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
|
// needs a token, sent as Authorization: Bearer <token>: the metrics
|
||||||
// SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without it is
|
// 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
|
// refused with 401. An endpoint whose token is unset answers 404, as any other
|
||||||
// path does.
|
// path does.
|
||||||
func (rq *request) answerAdmin() {
|
func (rq *request) answerAdmin() {
|
||||||
@@ -250,7 +250,7 @@ type clientAnswer struct {
|
|||||||
Bans []state.BanEntry `json:"bans"`
|
Bans []state.BanEntry `json:"bans"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// showClient answers GET ClientsPath<ip> with what servewaf 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
|
// its counters, its history, which holds its country as last looked up and its
|
||||||
// offences, and its bans with their notes.
|
// offences, and its bans with their notes.
|
||||||
func (rq *request) showClient() {
|
func (rq *request) showClient() {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -10,15 +10,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/state"
|
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||||
// adminSecret is the SWWAF_ADMIN_TOKEN the tests set, and adminBearer
|
// adminSecret is the SWW_ADMIN_TOKEN the tests set, and adminBearer
|
||||||
// how a request carries it.
|
// how a request carries it.
|
||||||
adminSecret = "fedcba9876543210fedcba9876543210"
|
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||||
adminBearer = "Bearer " + adminSecret
|
adminBearer = "Bearer " + adminSecret
|
||||||
@@ -86,13 +86,13 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any other request under /_smallwebwaf/ is not found.
|
// Any other request under /_sww/ is not found.
|
||||||
for _, e := range []adminEndpoint{
|
for _, e := range []adminEndpoint{
|
||||||
{http.MethodPut, servewaf.BansPath, banOtherClient},
|
{http.MethodPut, discriminator.BansPath, banOtherClient},
|
||||||
{http.MethodDelete, servewaf.BansPath, ""},
|
{http.MethodDelete, discriminator.BansPath, ""},
|
||||||
{http.MethodGet, servewaf.BansPath + "/" + otherClient, ""},
|
{http.MethodGet, discriminator.BansPath + "/" + otherClient, ""},
|
||||||
{http.MethodPost, servewaf.ClientsPath + otherClient, ""},
|
{http.MethodPost, discriminator.ClientsPath + otherClient, ""},
|
||||||
{http.MethodGet, strings.TrimSuffix(servewaf.ClientsPath, "/"), ""},
|
{http.MethodGet, strings.TrimSuffix(discriminator.ClientsPath, "/"), ""},
|
||||||
} {
|
} {
|
||||||
s.admin(e.method, e.path, e.body, http.StatusNotFound)
|
s.admin(e.method, e.path, e.body, http.StatusNotFound)
|
||||||
}
|
}
|
||||||
@@ -118,8 +118,8 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
|||||||
Reason: "probes for logins",
|
Reason: "probes for logins",
|
||||||
}
|
}
|
||||||
|
|
||||||
wantBans(t, s.admin(http.MethodPost, servewaf.BansPath, banOtherClient, http.StatusOK),
|
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, banOtherClient,
|
||||||
want)
|
http.StatusOK), want)
|
||||||
|
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
if line.BanExpires != requestlog.FormatTime(expires) {
|
if line.BanExpires != requestlog.FormatTime(expires) {
|
||||||
@@ -129,7 +129,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
|||||||
// Its notes count the request it refused.
|
// Its notes count the request it refused.
|
||||||
want.Notes.Requests, want.Notes.Refused = 1, 1
|
want.Notes.Requests, want.Notes.Refused = 1, 1
|
||||||
|
|
||||||
wantBans(t, s.admin(http.MethodGet, servewaf.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
|
// Ten minutes on, lifting the bans on client lifts that one, which is
|
||||||
// kept, marked lifted.
|
// kept, marked lifted.
|
||||||
@@ -138,13 +138,13 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
|||||||
lifted := clk.Now()
|
lifted := clk.Now()
|
||||||
want.Lifted = &lifted
|
want.Lifted = &lifted
|
||||||
|
|
||||||
wantBans(t, s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "",
|
wantBans(t, s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "",
|
||||||
http.StatusOK), want)
|
http.StatusOK), want)
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
wantBans(t, s.admin(http.MethodGet, servewaf.BansPath, "", http.StatusOK), want)
|
wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want)
|
||||||
|
|
||||||
// No ban on it is active any more.
|
// No ban on it is active any more.
|
||||||
s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "", http.StatusNotFound)
|
s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "", http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
||||||
@@ -181,7 +181,8 @@ func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
|||||||
want.Expires = &expires
|
want.Expires = &expires
|
||||||
}
|
}
|
||||||
|
|
||||||
wantBans(t, s.admin(http.MethodPost, servewaf.BansPath, body, http.StatusOK), want)
|
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, body, http.StatusOK),
|
||||||
|
want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +251,8 @@ func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
|
|||||||
"request body too large",
|
"request body too large",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
got := s.admin(http.MethodPost, servewaf.BansPath, tc.body, http.StatusBadRequest)
|
got := s.admin(http.MethodPost, discriminator.BansPath, tc.body,
|
||||||
|
http.StatusBadRequest)
|
||||||
if !strings.Contains(string(got.body), tc.want) {
|
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)
|
t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want)
|
||||||
}
|
}
|
||||||
@@ -283,7 +285,8 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
|||||||
Bans []state.BanEntry `json:"bans"`
|
Bans []state.BanEntry `json:"bans"`
|
||||||
}
|
}
|
||||||
|
|
||||||
decode(t, s.admin(http.MethodGet, servewaf.ClientsPath+addr, "", http.StatusOK), &got)
|
decode(t, s.admin(http.MethodGet, discriminator.ClientsPath+addr, "",
|
||||||
|
http.StatusOK), &got)
|
||||||
|
|
||||||
if got.Client == nil {
|
if got.Client == nil {
|
||||||
t.Fatalf("%s: no client", addr)
|
t.Fatalf("%s: no client", addr)
|
||||||
@@ -305,13 +308,15 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
|||||||
|
|
||||||
// Of an address no request came from and no ban covers, nothing is
|
// Of an address no request came from and no ban covers, nothing is
|
||||||
// known.
|
// known.
|
||||||
got := s.admin(http.MethodGet, servewaf.ClientsPath+"198.51.100.99", "", http.StatusOK)
|
got := s.admin(http.MethodGet, discriminator.ClientsPath+"198.51.100.99", "",
|
||||||
|
http.StatusOK)
|
||||||
if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
|
if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
|
||||||
t.Errorf("an unknown client is answered\n%s", got.body)
|
t.Errorf("an unknown client is answered\n%s", got.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.admin(http.MethodGet, servewaf.ClientsPath+"203.0.113", "", http.StatusBadRequest)
|
s.admin(http.MethodGet, discriminator.ClientsPath+"203.0.113", "",
|
||||||
s.admin(http.MethodDelete, servewaf.BansPath+"/203.0.113.0/24", "",
|
http.StatusBadRequest)
|
||||||
|
s.admin(http.MethodDelete, discriminator.BansPath+"/203.0.113.0/24", "",
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,24 +337,24 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminEndpoint is a request to an endpoint SWWAF_ADMIN_TOKEN opens.
|
// adminEndpoint is a request to an endpoint SWW_ADMIN_TOKEN opens.
|
||||||
type adminEndpoint struct {
|
type adminEndpoint struct {
|
||||||
method, path, body string
|
method, path, body string
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminEndpoints returns a request to each endpoint SWWAF_ADMIN_TOKEN
|
// adminEndpoints returns a request to each endpoint SWW_ADMIN_TOKEN
|
||||||
// opens: listing the bans, banning otherClient for an hour, lifting the
|
// opens: listing the bans, banning otherClient for an hour, lifting the
|
||||||
// bans on otherClient, and asking about otherClient.
|
// bans on otherClient, and asking about otherClient.
|
||||||
func adminEndpoints() []adminEndpoint {
|
func adminEndpoints() []adminEndpoint {
|
||||||
return []adminEndpoint{
|
return []adminEndpoint{
|
||||||
{http.MethodGet, servewaf.BansPath, ""},
|
{http.MethodGet, discriminator.BansPath, ""},
|
||||||
{http.MethodPost, servewaf.BansPath, banOtherClient},
|
{http.MethodPost, discriminator.BansPath, banOtherClient},
|
||||||
{http.MethodDelete, servewaf.BansPath + "/" + otherClient, ""},
|
{http.MethodDelete, discriminator.BansPath + "/" + otherClient, ""},
|
||||||
{http.MethodGet, servewaf.ClientsPath + otherClient, ""},
|
{http.MethodGet, discriminator.ClientsPath + otherClient, ""},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// admin sends a request with method for path, with body, to servewaf'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
|
// address, with the admin token, and checks that it is answered with status, its
|
||||||
// log line's action admin. It returns the answer.
|
// log line's action admin. It returns the answer.
|
||||||
func (s *sender) admin(method, path, body string, status int) answer {
|
func (s *sender) admin(method, path, body string, status int) answer {
|
||||||
@@ -365,7 +370,7 @@ func (s *sender) adminRequest(
|
|||||||
) answer {
|
) answer {
|
||||||
s.t.Helper()
|
s.t.Helper()
|
||||||
|
|
||||||
req := newRequest(s.t, method, s.serveWAFAddr, path, strings.NewReader(body))
|
req := newRequest(s.t, method, s.discriminatorAddr, path, strings.NewReader(body))
|
||||||
if authorization != "" {
|
if authorization != "" {
|
||||||
req.Header.Set("Authorization", authorization)
|
req.Header.Set("Authorization", authorization)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
"maps"
|
||||||
@@ -10,13 +10,13 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL"
|
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
|
||||||
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
|
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
|
||||||
// alertInstance is the instance every alert of these tests gives.
|
// alertInstance is the instance every alert of these tests gives.
|
||||||
alertInstance = "fsn1app1/blog"
|
alertInstance = "fsn1app1/blog"
|
||||||
)
|
)
|
||||||
@@ -201,7 +201,7 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
|
|||||||
// never sent them, and returns the queue they wait in as well.
|
// never sent them, and returns the queue they wait in as well.
|
||||||
func startWithAlerts(
|
func startWithAlerts(
|
||||||
t *testing.T, env map[string]string,
|
t *testing.T, env map[string]string,
|
||||||
) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
|
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
|
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
|
||||||
@@ -210,21 +210,21 @@ func startWithAlerts(
|
|||||||
// startAppWithAlerts is startWithAlerts in front of the app handler.
|
// startAppWithAlerts is startWithAlerts in front of the app handler.
|
||||||
func startAppWithAlerts(
|
func startAppWithAlerts(
|
||||||
t *testing.T, handler http.HandlerFunc, env map[string]string,
|
t *testing.T, handler http.HandlerFunc, env map[string]string,
|
||||||
) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
|
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
app := startApp(t, handler)
|
app := startApp(t, handler)
|
||||||
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
alertWebhookURL: "https://alerts.example/smallwebwaf",
|
alertWebhookURL: "https://alerts.example/sww",
|
||||||
instanceName: alertInstance,
|
instanceName: alertInstance,
|
||||||
}
|
}
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings)
|
addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings)
|
||||||
|
|
||||||
s := &sender{t: t, addr: addr, serveWAFAddr: server.Addr, out: out}
|
s := &sender{t: t, addr: addr, discriminatorAddr: server.Addr, out: out}
|
||||||
|
|
||||||
return s, clk, server, queue
|
return s, clk, server, queue
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
"maps"
|
||||||
@@ -13,19 +13,19 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The anomaly thresholds: the prefix of a scope followed by the end of a
|
// The anomaly thresholds: the prefix of a scope followed by the end of a
|
||||||
// count.
|
// count.
|
||||||
const (
|
const (
|
||||||
anomalyClient = "SWWAF_ANOMALY_CLIENT_"
|
anomalyClient = "SWW_ANOMALY_CLIENT_"
|
||||||
anomalyNet = "SWWAF_ANOMALY_NET_"
|
anomalyNet = "SWW_ANOMALY_NET_"
|
||||||
anomalyASN = "SWWAF_ANOMALY_ASN_"
|
anomalyASN = "SWW_ANOMALY_ASN_"
|
||||||
anomalyTotal = "SWWAF_ANOMALY_TOTAL_"
|
anomalyTotal = "SWW_ANOMALY_TOTAL_"
|
||||||
anomalyWatch = "SWWAF_WATCH_"
|
anomalyWatch = "SWW_WATCH_"
|
||||||
|
|
||||||
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
||||||
requestsPerHour = "REQUESTS_PER_HOUR"
|
requestsPerHour = "REQUESTS_PER_HOUR"
|
||||||
@@ -35,9 +35,9 @@ const (
|
|||||||
|
|
||||||
// The other anomaly settings.
|
// The other anomaly settings.
|
||||||
const (
|
const (
|
||||||
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX"
|
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
|
||||||
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX"
|
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
|
||||||
watchNets = "SWWAF_WATCH_NETS"
|
watchNets = "SWW_WATCH_NETS"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -164,9 +164,9 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS
|
allowed = "192.0.2.7" // in SWW_ALLOW_NETS
|
||||||
exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
denied = "192.0.2.20" // in SWWAF_DENY_NETS
|
denied = "192.0.2.20" // in SWW_DENY_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
||||||
@@ -319,7 +319,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
|
|||||||
// kept.
|
// kept.
|
||||||
geojsURL, _, release := startHeldGeoJS(t)
|
geojsURL, _, release := startHeldGeoJS(t)
|
||||||
|
|
||||||
var server atomic.Pointer[servewaf.Server]
|
var server atomic.Pointer[discriminator.Server]
|
||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
release()
|
release()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -15,25 +15,25 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
|
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
|
||||||
// servewaf has to answer.
|
// discriminator has to answer.
|
||||||
answerTimeLimit = 2 * time.Second
|
answerTimeLimit = 2 * time.Second
|
||||||
// defaultLookupWait is the default of SWWAF_LOOKUP_TIMEOUT.
|
// defaultLookupWait is the default of SWW_LOOKUP_TIMEOUT.
|
||||||
defaultLookupWait = 800 * time.Millisecond
|
defaultLookupWait = 800 * time.Millisecond
|
||||||
// The header limits SPEC.md gives.
|
// The header limits SPEC.md gives.
|
||||||
cookieLimit = 50
|
cookieLimit = 50
|
||||||
headerFieldLimit = 100
|
headerFieldLimit = 100
|
||||||
// cookieHeader is the header the cookie limit counts.
|
// cookieHeader is the header the cookie limit counts.
|
||||||
cookieHeader = "Cookie"
|
cookieHeader = "Cookie"
|
||||||
// tooLarge is how servewaf refuses a request over the header limits.
|
// tooLarge is how discriminator refuses a request over the header limits.
|
||||||
tooLarge = http.StatusRequestHeaderFieldsTooLarge
|
tooLarge = http.StatusRequestHeaderFieldsTooLarge
|
||||||
// silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in
|
// silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in
|
||||||
// that unansweredGeoJSURL names too.
|
// that unansweredGeoJSURL names too.
|
||||||
@@ -56,7 +56,7 @@ func cookies(count int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fillHeaders adds headers to a question until the checks see total header
|
// fillHeaders adds headers to a question until the checks see total header
|
||||||
// fields: all but ClientHeader and PeerHeader, which servewaf 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
|
// client sends nothing else but Host, which the limit does not count, once
|
||||||
// User-Agent is set.
|
// User-Agent is set.
|
||||||
func fillHeaders(req *http.Request, total int) {
|
func fillHeaders(req *http.Request, total int) {
|
||||||
@@ -70,7 +70,7 @@ func fillHeaders(req *http.Request, total int) {
|
|||||||
func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startServeWAF(t, "", map[string]string{wafMode: block})
|
s := startDiscriminator(t, "", map[string]string{wafMode: block})
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
|||||||
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantDisposition(t, got, tooLarge, servewaf.DispositionDeny)
|
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
||||||
|
|
||||||
line := s.out.requestLine(t)
|
line := s.out.requestLine(t)
|
||||||
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||||||
@@ -136,7 +136,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startServeWAF(t, "", map[string]string{wafMode: block})
|
s := startDiscriminator(t, "", map[string]string{wafMode: block})
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
tc.make(req)
|
tc.make(req)
|
||||||
|
|
||||||
@@ -144,13 +144,13 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
|||||||
line := s.out.requestLine(t)
|
line := s.out.requestLine(t)
|
||||||
|
|
||||||
if tc.refused {
|
if tc.refused {
|
||||||
wantDisposition(t, got, tooLarge, servewaf.DispositionDeny)
|
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
||||||
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
if line.DurationWAF == nil {
|
if line.DurationWAF == nil {
|
||||||
@@ -166,16 +166,16 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
|
|||||||
for _, env := range []map[string]string{
|
for _, env := range []map[string]string{
|
||||||
{wafMode: off},
|
{wafMode: off},
|
||||||
{wafMode: "detect"},
|
{wafMode: "detect"},
|
||||||
{wafMode: block, "SWWAF_WAF_EXEMPT_PATHS": "/"},
|
{wafMode: block, "SWW_WAF_EXEMPT_PATHS": "/"},
|
||||||
} {
|
} {
|
||||||
t.Run(fmt.Sprint(env), func(t *testing.T) {
|
t.Run(fmt.Sprint(env), func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startServeWAF(t, "", env)
|
s := startDiscriminator(t, "", env)
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
wantDisposition(t, do(t, req), tooLarge, servewaf.DispositionDeny)
|
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,17 +183,17 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
|
|||||||
func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
|
func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startServeWAF(t, "", map[string]string{wafMode: block, allowNets: client})
|
s := startDiscriminator(t, "", map[string]string{wafMode: block, allowNets: client})
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow)
|
wantDisposition(t, do(t, req), http.StatusOK, discriminator.DispositionAllow)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startServeWAF(t, "", map[string]string{mode: observe, wafMode: block})
|
s := startDiscriminator(t, "", map[string]string{mode: observe, wafMode: block})
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||||||
|
|
||||||
@@ -204,7 +204,7 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
|||||||
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||||||
|
|
||||||
line := s.out.requestLine(t)
|
line := s.out.requestLine(t)
|
||||||
if line.WouldAction != requestlog.ActionTooLarge || line.DurationWAF != nil {
|
if line.WouldAction != requestlog.ActionTooLarge || line.DurationWAF != nil {
|
||||||
@@ -216,7 +216,7 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
|||||||
func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startServeWAF(t, "", map[string]string{rateLimitPerMinute: "1"})
|
s := startDiscriminator(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||||
ask := func() answer {
|
ask := func() answer {
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
@@ -224,8 +224,8 @@ func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
|||||||
return do(t, req)
|
return do(t, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantDisposition(t, ask(), tooLarge, servewaf.DispositionDeny)
|
wantDisposition(t, ask(), tooLarge, discriminator.DispositionDeny)
|
||||||
wantDisposition(t, ask(), http.StatusForbidden, servewaf.DispositionDeny)
|
wantDisposition(t, ask(), http.StatusForbidden, discriminator.DispositionDeny)
|
||||||
|
|
||||||
lines := s.out.requestLines(t, 2)
|
lines := s.out.requestLines(t, 2)
|
||||||
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionRateLimited)
|
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
@@ -243,13 +243,13 @@ func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startServeWAF(t, "", map[string]string{
|
s := startDiscriminator(t, "", map[string]string{
|
||||||
trapPaths: trapPathList, rulesDir: writeRules(t, testRules),
|
trapPaths: trapPathList, rulesDir: writeRules(t, testRules),
|
||||||
})
|
})
|
||||||
req := s.question(siteA, client, tc.path)
|
req := s.question(siteA, client, tc.path)
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
wantDisposition(t, do(t, req), http.StatusForbidden, servewaf.DispositionDeny)
|
wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
|
||||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action)
|
wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -259,13 +259,13 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s := startServeWAF(t, geojsURL, map[string]string{
|
s := startDiscriminator(t, geojsURL, map[string]string{
|
||||||
unknownLimitPercent: "0", lookupTimeout: "1h",
|
unknownLimitPercent: "0", lookupTimeout: "1h",
|
||||||
})
|
})
|
||||||
req := s.question(siteA, fromDE, "/")
|
req := s.question(siteA, fromDE, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
wantDisposition(t, do(t, req), tooLarge, servewaf.DispositionDeny)
|
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
|
||||||
|
|
||||||
line := s.out.requestLine(t)
|
line := s.out.requestLine(t)
|
||||||
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||||||
@@ -275,14 +275,14 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The client is under no ban, and GeoJS places it in a country.
|
// The client is under no ban, and GeoJS places it in a country.
|
||||||
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, servewaf.DispositionAllow)
|
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, discriminator.DispositionAllow)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
|
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s := startServeWAF(t, geojsURL, map[string]string{unknownLimitPercent: "0"})
|
s := startDiscriminator(t, geojsURL, map[string]string{unknownLimitPercent: "0"})
|
||||||
s.server.GeoJS.Load([]lookup.Answer{{
|
s.server.GeoJS.Load([]lookup.Answer{{
|
||||||
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
|
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
|
||||||
Answered: s.clock.Now(), Used: s.clock.Now(),
|
Answered: s.clock.Now(), Used: s.clock.Now(),
|
||||||
@@ -291,7 +291,7 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T
|
|||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
wantDisposition(t, do(t, req), http.StatusForbidden, servewaf.DispositionDeny)
|
wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
|
||||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden, requestlog.ActionRateLimited)
|
wantLine(t, s.out.requestLine(t), http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,14 +299,14 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s := startServeWAF(t, geojsURL, map[string]string{
|
s := startDiscriminator(t, geojsURL, map[string]string{
|
||||||
mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
|
mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
|
||||||
})
|
})
|
||||||
req := s.question(siteA, fromDE, "/")
|
req := s.question(siteA, fromDE, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
got := do(t, req)
|
got := do(t, req)
|
||||||
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
||||||
|
|
||||||
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
|
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
|
||||||
if asn != asnDE || country != "DE" {
|
if asn != asnDE || country != "DE" {
|
||||||
@@ -315,18 +315,18 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newServeWAFInBubble makes a servewaf 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
|
// answers, a setting needs its answer, and everything is at its default but
|
||||||
// for env. No request to it may use the network.
|
// for env. No request to it may use the network.
|
||||||
func newServeWAFInBubble(
|
func newDiscriminatorInBubble(
|
||||||
t *testing.T, env map[string]string,
|
t *testing.T, env map[string]string,
|
||||||
) (*servewaf.Server, *output) {
|
) (*discriminator.Server, *output) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{deniedCountries: "KP"}
|
settings := map[string]string{deniedCountries: "KP"}
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now, settings)
|
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, settings)
|
||||||
|
|
||||||
return server, out
|
return server, out
|
||||||
}
|
}
|
||||||
@@ -334,13 +334,13 @@ func newServeWAFInBubble(
|
|||||||
// askDirectly hands a question about a request from client straight to server,
|
// 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.
|
// as change leaves it, and returns the answer's status and how long it took.
|
||||||
func askDirectly(
|
func askDirectly(
|
||||||
t *testing.T, server *servewaf.Server, change func(*http.Request),
|
t *testing.T, server *discriminator.Server, change func(*http.Request),
|
||||||
) (int, time.Duration) {
|
) (int, time.Duration) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||||||
req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234")
|
req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234")
|
||||||
req.Header.Set(servewaf.ClientHeader, client)
|
req.Header.Set(discriminator.ClientHeader, client)
|
||||||
req.Header.Set(requestIDHeader, "the-id")
|
req.Header.Set(requestIDHeader, "the-id")
|
||||||
change(req)
|
change(req)
|
||||||
|
|
||||||
@@ -353,17 +353,17 @@ func askDirectly(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// banClient bans client at now.
|
// banClient bans client at now.
|
||||||
func banClient(_ *testing.T, server *servewaf.Server, now time.Time) {
|
func banClient(_ *testing.T, server *discriminator.Server, now time.Time) {
|
||||||
server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{})
|
server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// countClient counts one request from client at now for the rate limits.
|
// countClient counts one request from client at now for the rate limits.
|
||||||
func countClient(_ *testing.T, server *servewaf.Server, now time.Time) {
|
func countClient(_ *testing.T, server *discriminator.Server, now time.Time) {
|
||||||
server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit)
|
server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// listClient puts client on the blocklist at dropURL.
|
// listClient puts client on the blocklist at dropURL.
|
||||||
func listClient(t *testing.T, server *servewaf.Server, now time.Time) {
|
func listClient(t *testing.T, server *discriminator.Server, now time.Time) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
err := server.Lists.Load([]reputation.List{{
|
err := server.Lists.Load([]reputation.List{{
|
||||||
@@ -375,13 +375,13 @@ func listClient(t *testing.T, server *servewaf.Server, now time.Time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// decideOnClient puts client on the CrowdSec decision list.
|
// decideOnClient puts client on the CrowdSec decision list.
|
||||||
func decideOnClient(t *testing.T, server *servewaf.Server, now time.Time) {
|
func decideOnClient(t *testing.T, server *discriminator.Server, now time.Time) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
loadDecisions(t, server, now, clientsDecision)
|
loadDecisions(t, server, now, clientsDecision)
|
||||||
}
|
}
|
||||||
|
|
||||||
// leaveAsItIs prepares nothing.
|
// leaveAsItIs prepares nothing.
|
||||||
func leaveAsItIs(*testing.T, *servewaf.Server, time.Time) {}
|
func leaveAsItIs(*testing.T, *discriminator.Server, time.Time) {}
|
||||||
|
|
||||||
func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -392,14 +392,14 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
env map[string]string
|
env map[string]string
|
||||||
// prepare gets the server ready, at now, before the question.
|
// prepare gets the server ready, at now, before the question.
|
||||||
prepare func(t *testing.T, server *servewaf.Server, now time.Time)
|
prepare func(t *testing.T, server *discriminator.Server, now time.Time)
|
||||||
// cookie is the request's Cookie header.
|
// cookie is the request's Cookie header.
|
||||||
cookie string
|
cookie string
|
||||||
status int
|
status int
|
||||||
action string
|
action string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "SWWAF_DENY_NETS", env: map[string]string{denyNets: client},
|
name: "SWW_DENY_NETS", env: map[string]string{denyNets: client},
|
||||||
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionDenied,
|
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionDenied,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -435,7 +435,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
|||||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||||
// says, so that a request that does not wait takes no time at all.
|
// says, so that a request that does not wait takes no time at all.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
server, out := newServeWAFInBubble(t, tc.env)
|
server, out := newDiscriminatorInBubble(t, tc.env)
|
||||||
tc.prepare(t, server, time.Now())
|
tc.prepare(t, server, time.Now())
|
||||||
|
|
||||||
status, took := askDirectly(t, server, func(req *http.Request) {
|
status, took := askDirectly(t, server, func(req *http.Request) {
|
||||||
@@ -463,7 +463,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
env map[string]string
|
env map[string]string
|
||||||
// prepare gets the server ready, at now, before the question.
|
// prepare gets the server ready, at now, before the question.
|
||||||
prepare func(t *testing.T, server *servewaf.Server, now time.Time)
|
prepare func(t *testing.T, server *discriminator.Server, now time.Time)
|
||||||
status int
|
status int
|
||||||
action string
|
action string
|
||||||
}{
|
}{
|
||||||
@@ -492,7 +492,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
server, out := newServeWAFInBubble(t, tc.env)
|
server, out := newDiscriminatorInBubble(t, tc.env)
|
||||||
tc.prepare(t, server, time.Now())
|
tc.prepare(t, server, time.Now())
|
||||||
|
|
||||||
status, took := askDirectly(t, server, func(*http.Request) {})
|
status, took := askDirectly(t, server, func(*http.Request) {})
|
||||||
@@ -508,7 +508,9 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testing.T) {
|
func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
held, askedGeoJS, _ := startHeldGeoJS(t)
|
held, askedGeoJS, _ := startHeldGeoJS(t)
|
||||||
@@ -521,10 +523,12 @@ func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testin
|
|||||||
|
|
||||||
t.Cleanup(func() { _ = resolver.Close() })
|
t.Cleanup(func() { _ = resolver.Close() })
|
||||||
|
|
||||||
server, out, _ := newServeWAFWith(t, func(params servewaf.Params) *servewaf.Server {
|
server, out, _ := newDiscriminatorWith(t, func(
|
||||||
|
params discriminator.Params,
|
||||||
|
) *discriminator.Server {
|
||||||
params.AbuseIPDBURL = silentAbuseIPDBURL
|
params.AbuseIPDBURL = silentAbuseIPDBURL
|
||||||
|
|
||||||
return servewaf.New(params)
|
return discriminator.New(params)
|
||||||
}, held, time.Now, map[string]string{
|
}, held, time.Now, map[string]string{
|
||||||
deniedCountries: "KP", wafMode: block,
|
deniedCountries: "KP", wafMode: block,
|
||||||
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged
|
// banResponse is a refusal answered with SWW_BAN_RESPONSE, and logged
|
||||||
// with action.
|
// with action.
|
||||||
func (rq *request) banResponse(action string) *refusal {
|
func (rq *request) banResponse(action string) *refusal {
|
||||||
return &refusal{status: rq.h.config.BanResponse, action: action}
|
return &refusal{status: rq.h.config.BanResponse, action: action}
|
||||||
@@ -83,10 +83,10 @@ func (rq *request) countBytes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// countRefusal counts the request for the error burst once it has been
|
// countRefusal counts the request for the error burst once it has been
|
||||||
// answered, if servewaf refused it after a rule file match, a trap path or a
|
// 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
|
// Core Rule Set match, and in observe mode if enforce mode would have: more than
|
||||||
// SWWAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute
|
// SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute
|
||||||
// break a limit. A client in SWWAF_ALLOW_NETS, which the checks skip, is
|
// break a limit. A client in SWW_ALLOW_NETS, which the checks skip, is
|
||||||
// not counted, and nothing is while the threshold is off.
|
// not counted, and nothing is while the threshold is off.
|
||||||
func (rq *request) countRefusal() {
|
func (rq *request) countRefusal() {
|
||||||
cfg := rq.h.config
|
cfg := rq.h.config
|
||||||
@@ -121,7 +121,7 @@ func (rq *request) countRefusal() {
|
|||||||
|
|
||||||
// countedBytes returns the request's bytes, once it has ended, as the
|
// countedBytes returns the request's bytes, once it has ended, as the
|
||||||
// byte limits and the anomaly thresholds count them: the response's body
|
// byte limits and the anomaly thresholds count them: the response's body
|
||||||
// bytes, the request's, or both, as SWWAF_BYTES_COUNT says.
|
// bytes, the request's, or both, as SWW_BYTES_COUNT says.
|
||||||
func (rq *request) countedBytes() int64 {
|
func (rq *request) countedBytes() int64 {
|
||||||
response, request := rq.out.bytes, rq.requestBytes
|
response, request := rq.out.bytes, rq.requestBytes
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ func (rq *request) countedBytes() int64 {
|
|||||||
// banForLimit bans the client's netblock at now for a broken limit, the
|
// banForLimit bans the client's netblock at now for a broken limit, the
|
||||||
// one hit names, notes the offence for the log line and counts the hit in
|
// one hit names, notes the offence for the log line and counts the hit in
|
||||||
// the metrics. status is what the client was sent, or is sent:
|
// the metrics. status is what the client was sent, or is sent:
|
||||||
// SWWAF_BAN_RESPONSE for a request over a rate limit, what the report says for
|
// SWW_BAN_RESPONSE for a request over a rate limit, what the report says for
|
||||||
// one whose bytes broke a byte limit, the refusal for one that broke the
|
// one whose bytes broke a byte limit, the refusal for one that broke the
|
||||||
// error burst. The ban's notes give the client's limit percentage for a
|
// error burst. The ban's notes give the client's limit percentage for a
|
||||||
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
||||||
@@ -277,7 +277,7 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
|
|||||||
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
||||||
// made at now would be sent. In observe mode the ban the request would
|
// made at now would be sent. In observe mode the ban the request would
|
||||||
// have made is worked out only then, at most once per
|
// have made is worked out only then, at most once per
|
||||||
// SWWAF_ALERT_COOLDOWN and never with no webhook set: its notes count the
|
// SWW_ALERT_COOLDOWN and never with no webhook set: its notes count the
|
||||||
// netblock's requests, which can mean going through every client.
|
// netblock's requests, which can mean going through every client.
|
||||||
func (rq *request) wouldAlertBan(
|
func (rq *request) wouldAlertBan(
|
||||||
netblock netip.Prefix, now time.Time, cause string,
|
netblock netip.Prefix, now time.Time, cause string,
|
||||||
@@ -343,7 +343,7 @@ func (rq *request) netblockRequests(netblock netip.Prefix) int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// netblock is the netblock a ban on client covers: its IPv4 address,
|
// netblock is the netblock a ban on client covers: its IPv4 address,
|
||||||
// widened to SWWAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
// widened to SWW_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
||||||
// counts it in.
|
// counts it in.
|
||||||
func (h *handler) netblock(client netip.Addr) netip.Prefix {
|
func (h *handler) netblock(client netip.Addr) netip.Prefix {
|
||||||
addr := client.Unmap()
|
addr := client.Unmap()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -26,7 +26,7 @@ const (
|
|||||||
userAgent = "ban-test/1.0"
|
userAgent = "ban-test/1.0"
|
||||||
// permanent is the log line's ban_expires for a permanent ban.
|
// permanent is the log line's ban_expires for a permanent ban.
|
||||||
permanent = "permanent"
|
permanent = "permanent"
|
||||||
// banResponseClose is the SWWAF_BAN_RESPONSE that closes the connection.
|
// banResponseClose is the SWW_BAN_RESPONSE that closes the connection.
|
||||||
banResponseClose = "close"
|
banResponseClose = "close"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -89,19 +89,19 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A first ban lasts SWWAF_LIMIT_BAN_DURATION; one within
|
// A first ban lasts SWW_LIMIT_BAN_DURATION; one within
|
||||||
// SWWAF_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
|
// SWW_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
|
||||||
wantExpires(breakLimit(), 10*time.Minute)
|
wantExpires(breakLimit(), 10*time.Minute)
|
||||||
clk.advance(10*time.Minute + time.Hour)
|
clk.advance(10*time.Minute + time.Hour)
|
||||||
wantExpires(breakLimit(), 30*time.Minute)
|
wantExpires(breakLimit(), 30*time.Minute)
|
||||||
|
|
||||||
// Later than that, SWWAF_LIMIT_BAN_DURATION again.
|
// Later than that, SWW_LIMIT_BAN_DURATION again.
|
||||||
clk.advance(30*time.Minute + time.Hour + time.Second)
|
clk.advance(30*time.Minute + time.Hour + time.Second)
|
||||||
wantExpires(breakLimit(), 10*time.Minute)
|
wantExpires(breakLimit(), 10*time.Minute)
|
||||||
clk.advance(10 * time.Minute)
|
clk.advance(10 * time.Minute)
|
||||||
wantExpires(breakLimit(), 30*time.Minute)
|
wantExpires(breakLimit(), 30*time.Minute)
|
||||||
|
|
||||||
// 90 minutes would be longer than SWWAF_MAX_BAN_DURATION: the ban is
|
// 90 minutes would be longer than SWW_MAX_BAN_DURATION: the ban is
|
||||||
// permanent.
|
// permanent.
|
||||||
clk.advance(30 * time.Minute)
|
clk.advance(30 * time.Minute)
|
||||||
|
|
||||||
@@ -148,8 +148,8 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
|
|
||||||
// In the IPv4 cases, client breaks the limit; these two are next to it.
|
// In the IPv4 cases, client breaks the limit; these two are next to it.
|
||||||
const (
|
const (
|
||||||
allowed = "203.0.113.60" // in SWWAF_ALLOW_NETS
|
allowed = "203.0.113.60" // in SWW_ALLOW_NETS
|
||||||
exempt = "203.0.113.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "203.0.113.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -164,7 +164,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
nil, []string{otherClient, exempt},
|
nil, []string{otherClient, exempt},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the IPv4 netblock SWWAF_BAN_SCOPE_V4_PREFIX sets",
|
"the IPv4 netblock SWW_BAN_SCOPE_V4_PREFIX sets",
|
||||||
map[string]string{banScopeV4Prefix: "24"}, client,
|
map[string]string{banScopeV4Prefix: "24"}, client,
|
||||||
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed},
|
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed},
|
||||||
},
|
},
|
||||||
@@ -173,7 +173,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
|
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the IPv6 netblock SWWAF_IPV6_GROUP_PREFIX sets",
|
"the IPv6 netblock SWW_IPV6_GROUP_PREFIX sets",
|
||||||
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
|
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
|
||||||
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
|
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
|
||||||
},
|
},
|
||||||
@@ -231,10 +231,10 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
|
|||||||
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting string // "" leaves SWWAF_BAN_RESPONSE at its default
|
setting string // "" leaves SWW_BAN_RESPONSE at its default
|
||||||
status int // 0 is the connection closed without an answer
|
status int // 0 is the connection closed without an answer
|
||||||
}{
|
}{
|
||||||
{"", http.StatusForbidden},
|
{"", http.StatusForbidden},
|
||||||
@@ -352,7 +352,7 @@ func TestMaxBansDropsTheBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
|||||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
// clock is the time a test sets, by which smallwebwaf counts requests and
|
// clock is the time a test sets, by which sww counts requests and
|
||||||
// makes bans.
|
// makes bans.
|
||||||
type clock struct {
|
type clock struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -375,14 +375,14 @@ func (c *clock) advance(d time.Duration) {
|
|||||||
c.now = c.now.Add(d)
|
c.now = c.now.Add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startWithClock starts servewaf, 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
|
// with the settings in env on top of trusting localhost's
|
||||||
// X-Forwarded-For, clients' AS numbers and countries looked up at
|
// 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
|
// geojsURL, and a clock set to midnight, the start of a bucket in every
|
||||||
// window.
|
// window.
|
||||||
func startWithClock(
|
func startWithClock(
|
||||||
t *testing.T, geojsURL string, env map[string]string,
|
t *testing.T, geojsURL string, env map[string]string,
|
||||||
) (*sender, *clock, *servewaf.Server) {
|
) (*sender, *clock, *discriminator.Server) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
@@ -392,17 +392,18 @@ func startWithClock(
|
|||||||
|
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings)
|
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings)
|
||||||
|
|
||||||
return &sender{t: t, addr: addr, serveWAFAddr: 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
|
// sender sends requests to rproxy one after another, each on a connection of its
|
||||||
// own, and checks each one's answer and log line. serveWAFAddr is where servewaf's
|
// own, and checks each one's answer and log line. discriminatorAddr is where
|
||||||
// own endpoints are, which adminRequest asks. Every request servewaf gets must
|
// discriminator's own endpoints are, which adminRequest asks. Every request
|
||||||
// come through the sender, since the log lines are matched to them in order.
|
// discriminator gets must come through the sender, since the log lines are
|
||||||
|
// matched to them in order.
|
||||||
type sender struct {
|
type sender struct {
|
||||||
t *testing.T
|
t *testing.T
|
||||||
addr string
|
addr string
|
||||||
serveWAFAddr string
|
discriminatorAddr string
|
||||||
out *output
|
out *output
|
||||||
sent int
|
sent int
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
@@ -18,7 +18,7 @@ type percentage struct {
|
|||||||
|
|
||||||
// biasedThresholdsSet reports whether a biased threshold can lower a
|
// biasedThresholdsSet reports whether a biased threshold can lower a
|
||||||
// client's limits: one of its lists is not empty,
|
// client's limits: one of its lists is not empty,
|
||||||
// SWWAF_UNKNOWN_LIMIT_PERCENT is below 100, or SWWAF_ASN_LIMIT_PERCENT_URL
|
// SWW_UNKNOWN_LIMIT_PERCENT is below 100, or SWW_ASN_LIMIT_PERCENT_URL
|
||||||
// is set. The client's lookup is then needed before its request goes on.
|
// is set. The client's lookup is then needed before its request goes on.
|
||||||
func biasedThresholdsSet(cfg *config.Config) bool {
|
func biasedThresholdsSet(cfg *config.Config) bool {
|
||||||
return len(cfg.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 0 ||
|
return len(cfg.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 0 ||
|
||||||
@@ -31,15 +31,15 @@ func biasedThresholdsSet(cfg *config.Config) bool {
|
|||||||
// up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB
|
// up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB
|
||||||
// that list it. Each is the lowest of those the settings give it, the
|
// that list it. Each is the lowest of those the settings give it, the
|
||||||
// first of them in the order below when several are lowest: the
|
// first of them in the order below when several are lowest: the
|
||||||
// percentage SWWAF_ASN_LIMIT_PERCENT gives its AS number, the one the file
|
// percentage SWW_ASN_LIMIT_PERCENT gives its AS number, the one the file
|
||||||
// SWWAF_ASN_LIMIT_PERCENT_URL names gives it, the one
|
// SWW_ASN_LIMIT_PERCENT_URL names gives it, the one
|
||||||
// SWWAF_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
|
// SWW_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
|
||||||
// country, unless its request skipped the wait for GeoJS,
|
// country, unless its request skipped the wait for GeoJS,
|
||||||
// SWWAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, the
|
// SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, the
|
||||||
// percentage of SWWAF_BLOCKLIST_ACTION while it is limit, and for a client
|
// percentage of SWW_BLOCKLIST_ACTION while it is limit, and for a client
|
||||||
// a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit, the
|
// a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit, the
|
||||||
// percentage of SWWAF_REPUTATION_ACTION while it is limit. For the byte
|
// percentage of SWW_REPUTATION_ACTION while it is limit. For the byte
|
||||||
// limits, SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT take the
|
// limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT take the
|
||||||
// place of the first three for an AS number or a country they list.
|
// place of the first three for an AS number or a country they list.
|
||||||
func (rq *request) limitPercentages() (percentage, percentage) {
|
func (rq *request) limitPercentages() (percentage, percentage) {
|
||||||
cfg := rq.h.config
|
cfg := rq.h.config
|
||||||
@@ -47,36 +47,36 @@ func (rq *request) limitPercentages() (percentage, percentage) {
|
|||||||
|
|
||||||
unknown := percentage{percent: whole}
|
unknown := percentage{percent: whole}
|
||||||
if country == "" && !rq.skippedLookupWait {
|
if country == "" && !rq.skippedLookupWait {
|
||||||
unknown = percentage{cfg.UnknownLimitPercent, "SWWAF_UNKNOWN_LIMIT_PERCENT"}
|
unknown = percentage{cfg.UnknownLimitPercent, "SWW_UNKNOWN_LIMIT_PERCENT"}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetched := percentage{percent: whole}
|
fetched := percentage{percent: whole}
|
||||||
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
|
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
|
||||||
fetched = percentage{percent, "SWWAF_ASN_LIMIT_PERCENT_URL"}
|
fetched = percentage{percent, "SWW_ASN_LIMIT_PERCENT_URL"}
|
||||||
}
|
}
|
||||||
|
|
||||||
blocklisted := percentage{percent: whole}
|
blocklisted := percentage{percent: whole}
|
||||||
if rq.blocklisted && cfg.BlocklistAction == "limit" {
|
if rq.blocklisted && cfg.BlocklistAction == "limit" {
|
||||||
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWWAF_BLOCKLIST_ACTION"}
|
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_BLOCKLIST_ACTION"}
|
||||||
}
|
}
|
||||||
|
|
||||||
reputationListed := percentage{percent: whole}
|
reputationListed := percentage{percent: whole}
|
||||||
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
|
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
|
||||||
reputationListed = percentage{cfg.ReputationLimitPercent, "SWWAF_REPUTATION_ACTION"}
|
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"}
|
||||||
}
|
}
|
||||||
|
|
||||||
asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWWAF_ASN_LIMIT_PERCENT"),
|
asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWW_ASN_LIMIT_PERCENT"),
|
||||||
fetched)
|
fetched)
|
||||||
countryRequests := given(cfg.CountryLimitPercent, country,
|
countryRequests := given(cfg.CountryLimitPercent, country,
|
||||||
"SWWAF_COUNTRY_LIMIT_PERCENT")
|
"SWW_COUNTRY_LIMIT_PERCENT")
|
||||||
|
|
||||||
asnBytes, countryBytes := asnRequests, countryRequests
|
asnBytes, countryBytes := asnRequests, countryRequests
|
||||||
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
||||||
asnBytes = given(cfg.ASNBytesPercent, asn, "SWWAF_ASN_BYTES_PERCENT")
|
asnBytes = given(cfg.ASNBytesPercent, asn, "SWW_ASN_BYTES_PERCENT")
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, listed := cfg.CountryBytesPercent[country]; listed {
|
if _, listed := cfg.CountryBytesPercent[country]; listed {
|
||||||
countryBytes = given(cfg.CountryBytesPercent, country, "SWWAF_COUNTRY_BYTES_PERCENT")
|
countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT")
|
||||||
}
|
}
|
||||||
|
|
||||||
return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed),
|
return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed),
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -15,18 +15,18 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The biased thresholds.
|
// The biased thresholds.
|
||||||
const (
|
const (
|
||||||
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT"
|
asnLimitPercent = "SWW_ASN_LIMIT_PERCENT"
|
||||||
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT"
|
countryLimitPercent = "SWW_COUNTRY_LIMIT_PERCENT"
|
||||||
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT"
|
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
||||||
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT"
|
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
||||||
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT"
|
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -230,7 +230,7 @@ func TestLowestPercentageApplies(t *testing.T) {
|
|||||||
noCountry, "25 from " + unknownLimitPercent,
|
noCountry, "25 from " + unknownLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// SWWAF_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
// SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
||||||
"the AS number's, for a client without a country",
|
"the AS number's, for a client without a country",
|
||||||
map[string]string{asnLimitPercent: "AS64500:25"},
|
map[string]string{asnLimitPercent: "AS64500:25"},
|
||||||
noCountry, "25 from " + asnLimitPercent,
|
noCountry, "25 from " + asnLimitPercent,
|
||||||
@@ -280,7 +280,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
|||||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||||
// says, with a GeoJS that never answers.
|
// says, with a GeoJS that never answers.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now,
|
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now,
|
||||||
map[string]string{unknownLimitPercent: "0"})
|
map[string]string{unknownLimitPercent: "0"})
|
||||||
|
|
||||||
// Once the time the request waits for its answer is up, the client
|
// Once the time the request waits for its answer is up, the client
|
||||||
@@ -319,7 +319,7 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
|||||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||||
// says, with a GeoJS that never answers.
|
// says, with a GeoJS that never answers.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now,
|
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now,
|
||||||
map[string]string{lookupTimeout: timeout.String(), tc.setting: tc.value})
|
map[string]string{lookupTimeout: timeout.String(), tc.setting: tc.value})
|
||||||
began := time.Now()
|
began := time.Now()
|
||||||
|
|
||||||
@@ -419,7 +419,7 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
|
|||||||
// clock.
|
// clock.
|
||||||
func startWithLookups(
|
func startWithLookups(
|
||||||
t *testing.T, env map[string]string,
|
t *testing.T, env map[string]string,
|
||||||
) (*sender, *servewaf.Server, *alerts.Queue) {
|
) (*sender, *discriminator.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
s, _, server, queue := startWithLookupsAndClock(t, env)
|
s, _, server, queue := startWithLookupsAndClock(t, env)
|
||||||
@@ -434,7 +434,7 @@ func startWithLookups(
|
|||||||
// country, and no other address.
|
// country, and no other address.
|
||||||
func startWithLookupsAndClock(
|
func startWithLookupsAndClock(
|
||||||
t *testing.T, env map[string]string,
|
t *testing.T, env map[string]string,
|
||||||
) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
|
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||||
@@ -463,11 +463,13 @@ func (s *sender) uploadFrom(from string) logLine {
|
|||||||
// serveFromDE hands a question about a request from fromDE with method and body
|
// serveFromDE hands a question about a request from fromDE with method and body
|
||||||
// straight to server's handler, without the network, and returns once it is
|
// straight to server's handler, without the network, and returns once it is
|
||||||
// answered.
|
// answered.
|
||||||
func serveFromDE(t *testing.T, server *servewaf.Server, method string, body io.Reader) {
|
func serveFromDE(
|
||||||
|
t *testing.T, server *discriminator.Server, method string, body io.Reader,
|
||||||
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
|
req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
|
||||||
req.Header.Set(servewaf.ClientHeader, fromDE)
|
req.Header.Set(discriminator.ClientHeader, fromDE)
|
||||||
req.Header.Set(requestIDHeader, "the-id")
|
req.Header.Set(requestIDHeader, "the-id")
|
||||||
|
|
||||||
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
@@ -485,7 +487,7 @@ func wantPercent(t *testing.T, what string, percent *int64, setting, want string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// percentText gives a limit percentage and the setting that gave it as
|
// percentText gives a limit percentage and the setting that gave it as
|
||||||
// text, such as "50 from SWWAF_ASN_LIMIT_PERCENT", or none when both are
|
// text, such as "50 from SWW_ASN_LIMIT_PERCENT", or none when both are
|
||||||
// left out.
|
// left out.
|
||||||
func percentText(percent *int64, setting string) string {
|
func percentText(percent *int64, setting string) string {
|
||||||
switch {
|
switch {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -19,13 +19,13 @@ import (
|
|||||||
|
|
||||||
// The byte limit settings.
|
// The byte limit settings.
|
||||||
const (
|
const (
|
||||||
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE"
|
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
|
||||||
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR"
|
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
|
||||||
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY"
|
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
|
||||||
bytesCount = "SWWAF_BYTES_COUNT"
|
bytesCount = "SWW_BYTES_COUNT"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The values of SWWAF_BYTES_COUNT.
|
// The values of SWW_BYTES_COUNT.
|
||||||
const (
|
const (
|
||||||
countResponse = "response"
|
countResponse = "response"
|
||||||
countRequest = "request"
|
countRequest = "request"
|
||||||
@@ -40,7 +40,7 @@ const (
|
|||||||
// byteLimit is the byte limit these tests set, as a setting: a request
|
// byteLimit is the byte limit these tests set, as a setting: a request
|
||||||
// with a body and its answer, 100 bytes, go over it.
|
// with a body and its answer, 100 bytes, go over it.
|
||||||
byteLimit = "99"
|
byteLimit = "99"
|
||||||
// minuteBytes is limit_hit for SWWAF_BYTES_LIMIT_PER_MINUTE.
|
// minuteBytes is limit_hit for SWW_BYTES_LIMIT_PER_MINUTE.
|
||||||
minuteBytes = "minute_bytes"
|
minuteBytes = "minute_bytes"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
|
|||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
wantMetric(t, s.scrape(), `smallwebwaf_rate_limit_hits_total{`+
|
wantMetric(t, s.scrape(), `sww_rate_limit_hits_total{`+
|
||||||
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
|
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -212,8 +212,8 @@ func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS
|
allowed = "192.0.2.7" // in SWW_ALLOW_NETS
|
||||||
exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _ := startWithAnswers(t, map[string]string{
|
s, _ := startWithAnswers(t, map[string]string{
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -43,7 +43,7 @@ func scheme(r *http.Request, isQuestion bool) string {
|
|||||||
|
|
||||||
// clientGroup is the client a request is counted toward: its IPv4
|
// clientGroup is the client a request is counted toward: its IPv4
|
||||||
// address, or its IPv6 group, the netblock its IPv6 address is in of the
|
// address, or its IPv6 group, the netblock its IPv6 address is in of the
|
||||||
// length SWWAF_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
|
// length SWW_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
|
||||||
// usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
|
// usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
|
||||||
func (h *handler) clientGroup(addr netip.Addr) netip.Prefix {
|
func (h *handler) clientGroup(addr netip.Addr) netip.Prefix {
|
||||||
addr = addr.Unmap()
|
addr = addr.Unmap()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// checkCoreRuleSet inspects the request with the Core Rule Set, unless
|
// checkCoreRuleSet inspects the request with the Core Rule Set, unless
|
||||||
// SWWAF_WAF_MODE is off or SWWAF_WAF_EXEMPT_PATHS exempts its path, as
|
// SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as
|
||||||
// pathExempt decides, and notes the rules it matched and its score in the
|
// pathExempt decides, and notes the rules it matched and its score in the
|
||||||
// log line, and the rules in the metrics. A score at or over
|
// log line, and the rules in the metrics. A score at or over
|
||||||
// SWWAF_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert,
|
// SWW_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert,
|
||||||
// and in block mode refuses the request, which is an offence its client's
|
// and in block mode refuses the request, which is an offence its client's
|
||||||
// history counts, and so returns ActionWAFBlocked. It returns "" for a
|
// history counts, and so returns ActionWAFBlocked. It returns "" for a
|
||||||
// request it does not refuse.
|
// request it does not refuse.
|
||||||
@@ -55,7 +55,7 @@ func (rq *request) checkCoreRuleSet() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// alertWAFBlock raises the waf_block alert for the request, which the Core
|
// alertWAFBlock raises the waf_block alert for the request, which the Core
|
||||||
// Rule Set scored at result, at or over SWWAF_WAF_ANOMALY_THRESHOLD. Its
|
// Rule Set scored at result, at or over SWW_WAF_ANOMALY_THRESHOLD. Its
|
||||||
// detail gives the rule ids, the score, the method and the path with the
|
// detail gives the rule ids, the score, the method and the path with the
|
||||||
// query, and, for a request that is not refused for it, the mode: detect,
|
// query, and, for a request that is not refused for it, the mode: detect,
|
||||||
// or observe in observe mode.
|
// or observe in observe mode.
|
||||||
@@ -82,7 +82,7 @@ func (rq *request) alertWAFBlock(result waf.Result) {
|
|||||||
ASN: rq.line.ASN,
|
ASN: rq.line.ASN,
|
||||||
ASName: rq.line.ASName,
|
ASName: rq.line.ASName,
|
||||||
Country: rq.line.Country,
|
Country: rq.line.Country,
|
||||||
Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD",
|
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
|
||||||
Detail: detail,
|
Detail: detail,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
+17
-17
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
@@ -14,13 +14,13 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The Core Rule Set's settings the tests set, besides SWWAF_WAF_MODE, and
|
// The Core Rule Set's settings the tests set, besides SWW_WAF_MODE, and
|
||||||
// its two modes that inspect requests.
|
// its two modes that inspect requests.
|
||||||
const (
|
const (
|
||||||
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD"
|
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
|
||||||
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
|
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
||||||
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
|
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||||
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
|
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||||
block = "block"
|
block = "block"
|
||||||
detect = "detect"
|
detect = "detect"
|
||||||
)
|
)
|
||||||
@@ -28,7 +28,7 @@ const (
|
|||||||
// formData is the type of a form's body.
|
// formData is the type of a form's body.
|
||||||
const formData = "application/x-www-form-urlencoded"
|
const formData = "application/x-www-form-urlencoded"
|
||||||
|
|
||||||
// sizeLimit is SWWAF_WAF_BODY_LIMIT in the tests that read bodies, 1K as a
|
// sizeLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies, 1K as a
|
||||||
// setting.
|
// setting.
|
||||||
const (
|
const (
|
||||||
sizeLimit = 1 << 10
|
sizeLimit = 1 << 10
|
||||||
@@ -37,7 +37,7 @@ const (
|
|||||||
|
|
||||||
// sqlInjection asks for / with an SQL injection in its query, which only
|
// sqlInjection asks for / with an SQL injection in its query, which only
|
||||||
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
|
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
|
||||||
// SWWAF_WAF_ANOMALY_THRESHOLD.
|
// SWW_WAF_ANOMALY_THRESHOLD.
|
||||||
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
||||||
|
|
||||||
// wantWAF checks the request log line's waf_rule_ids and waf_score, and
|
// wantWAF checks the request log line's waf_rule_ids and waf_score, and
|
||||||
@@ -125,7 +125,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
const allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
wafMode: block,
|
wafMode: block,
|
||||||
@@ -134,7 +134,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
rulesDir: writeRules(t, testRules),
|
rulesDir: writeRules(t, testRules),
|
||||||
})
|
})
|
||||||
|
|
||||||
// A client in SWWAF_ALLOW_NETS, and a path SWWAF_WAF_EXEMPT_PATHS
|
// A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS
|
||||||
// exempts, are not inspected.
|
// exempts, are not inspected.
|
||||||
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWAF(t, line, nil)
|
wantWAF(t, line, nil)
|
||||||
@@ -153,7 +153,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
requestlog.ActionRuleBlocked)
|
requestlog.ActionRuleBlocked)
|
||||||
wantWAF(t, line, nil)
|
wantWAF(t, line, nil)
|
||||||
|
|
||||||
// With SWWAF_WAF_MODE off, no request is.
|
// With SWW_WAF_MODE off, no request is.
|
||||||
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
|
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
|
||||||
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWAF(t, line, nil)
|
wantWAF(t, line, nil)
|
||||||
@@ -367,10 +367,10 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
|
|||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_waf_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
|
`sww_waf_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`, 3)
|
`sww_offences_total{instance="app",kind="waf_blocked"}`, 3)
|
||||||
wantMetric(t, metrics, `smallwebwaf_requests_total{action="waf_blocked",`+
|
wantMetric(t, metrics, `sww_requests_total{action="waf_blocked",`+
|
||||||
`instance="app",status_class="4xx"}`, 3)
|
`instance="app",status_class="4xx"}`, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,9 +394,9 @@ func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T
|
|||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_waf_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
|
`sww_waf_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
|
||||||
wantNoSeries(t, metrics,
|
wantNoSeries(t, metrics,
|
||||||
`smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`)
|
`sww_offences_total{instance="app",kind="waf_blocked"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
|
func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
|
||||||
@@ -468,7 +468,7 @@ func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) {
|
|||||||
Host: appHost,
|
Host: appHost,
|
||||||
Client: netip.MustParseAddr(client),
|
Client: netip.MustParseAddr(client),
|
||||||
Netblock: netip.MustParsePrefix(client + "/32"),
|
Netblock: netip.MustParsePrefix(client + "/32"),
|
||||||
Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD",
|
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
|
||||||
Detail: detail,
|
Detail: detail,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"slices"
|
"slices"
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
// countryDenied reports whether the country lists refuse the request, by
|
// countryDenied reports whether the country lists refuse the request, by
|
||||||
// the client's country as it was looked up. A client without a country,
|
// the client's country as it was looked up. A client without a country,
|
||||||
// or whose country cannot be found, is refused only by
|
// or whose country cannot be found, is refused only by
|
||||||
// SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
// SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||||
func (rq *request) countryDenied() bool {
|
func (rq *request) countryDenied() bool {
|
||||||
denied := rq.h.config.DeniedCountries
|
denied := rq.h.config.DeniedCountries
|
||||||
allowed := rq.h.config.ExclusivelyAllowedCountries
|
allowed := rq.h.config.ExclusivelyAllowedCountries
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -245,11 +245,11 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
|
|||||||
action string
|
action string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"not in SWWAF_ALLOW_NETS", "",
|
"not in SWW_ALLOW_NETS", "",
|
||||||
http.StatusForbidden, requestlog.ActionCountryDenied,
|
http.StatusForbidden, requestlog.ActionCountryDenied,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"in SWWAF_ALLOW_NETS", "10.0.0.7,fd00::/8",
|
"in SWW_ALLOW_NETS", "10.0.0.7,fd00::/8",
|
||||||
http.StatusOK, requestlog.ActionForward,
|
http.StatusOK, requestlog.ActionForward,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -279,7 +279,7 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
|
|||||||
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
|
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
|
||||||
// each in an AS of its own, and no other address. It returns its URL, and
|
// each in an AS of its own, and no other address. It returns its URL, and
|
||||||
// what returns the addresses it has been asked about. A test that needs
|
// what returns the addresses it has been asked about. A test that needs
|
||||||
// the stand-in to be asked or to answer sets SWWAF_LOOKUP_TIMEOUT to an
|
// the stand-in to be asked or to answer sets SWW_LOOKUP_TIMEOUT to an
|
||||||
// hour, whether or not a request waits for the answer: on the default, a
|
// hour, whether or not a request waits for the answer: on the default, a
|
||||||
// hold-up of the test process can abandon the request to the stand-in, and
|
// hold-up of the test process can abandon the request to the stand-in, and
|
||||||
// leave the client unknown.
|
// leave the client unknown.
|
||||||
@@ -350,7 +350,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// geojsAnswer is an answer of GeoJS about one address, with the fields
|
// geojsAnswer is an answer of GeoJS about one address, with the fields
|
||||||
// smallwebwaf reads.
|
// sww reads.
|
||||||
//
|
//
|
||||||
//nolint:tagliatelle // GeoJS's own names
|
//nolint:tagliatelle // GeoJS's own names
|
||||||
type geojsAnswer struct {
|
type geojsAnswer struct {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -9,17 +9,17 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The CrowdSec settings, and the tests' engine, which is never asked: each
|
// The CrowdSec settings, and the tests' engine, which is never asked: each
|
||||||
// test puts in the copy of its decision list, at decisionsURL, that it
|
// test puts in the copy of its decision list, at decisionsURL, that it
|
||||||
// needs, as reputation.json would at start.
|
// needs, as reputation.json would at start.
|
||||||
const (
|
const (
|
||||||
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL"
|
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
|
||||||
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY"
|
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
|
||||||
lapi = "http://crowdsec.example:8080"
|
lapi = "http://crowdsec.example:8080"
|
||||||
decisionsURL = lapi + "/v1/decisions"
|
decisionsURL = lapi + "/v1/decisions"
|
||||||
bouncerKey = "crowdsec-key-0123456789abcdef"
|
bouncerKey = "crowdsec-key-0123456789abcdef"
|
||||||
@@ -87,11 +87,11 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
|
|||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}`
|
labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}`
|
||||||
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="crowdsec",`+
|
wantMetric(t, metrics, `sww_bans_made_total{cause="crowdsec",`+
|
||||||
`instance="`+alertInstance+`"}`, 1)
|
`instance="`+alertInstance+`"}`, 1)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 1)
|
wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 1)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_last_fetch_timestamp_seconds"+labels,
|
wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels,
|
||||||
float64(fetched.Unix()))
|
float64(fetched.Unix()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
|
|||||||
// decisionsURL, answer, the engine's answer, fetched at fetched, as
|
// decisionsURL, answer, the engine's answer, fetched at fetched, as
|
||||||
// reputation.json would at start.
|
// reputation.json would at start.
|
||||||
func loadDecisions(
|
func loadDecisions(
|
||||||
t *testing.T, server *servewaf.Server, fetched time.Time, answer string,
|
t *testing.T, server *discriminator.Server, fetched time.Time, answer string,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -17,18 +17,18 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The headers of rproxy's question and of servewaf's answer, as
|
// The headers of rproxy's question and of discriminator's answer, as
|
||||||
// "servewaf" in SPEC.md gives them.
|
// "discriminator" in SPEC.md gives them.
|
||||||
const (
|
const (
|
||||||
// ClientHeader marks a request as rproxy's question, and gives the
|
// ClientHeader marks a request as rproxy's question, and gives the
|
||||||
// visitor's address.
|
// visitor's address.
|
||||||
ClientHeader = "X-Smallwebwaf-Client"
|
ClientHeader = "X-Sww-Client"
|
||||||
// PeerHeader gives the address of rproxy's own TCP peer.
|
// PeerHeader gives the address of rproxy's own TCP peer.
|
||||||
PeerHeader = "X-Smallwebwaf-Peer"
|
PeerHeader = "X-Sww-Peer"
|
||||||
// ContentLengthHeader gives the body length the visitor announced.
|
// ContentLengthHeader gives the body length the visitor announced.
|
||||||
ContentLengthHeader = "X-Smallwebwaf-Content-Length"
|
ContentLengthHeader = "X-Sww-Content-Length"
|
||||||
// DispositionHeader gives servewaf's answer.
|
// DispositionHeader gives discriminator's answer.
|
||||||
DispositionHeader = "X-Smallwebwaf-Disposition"
|
DispositionHeader = "X-Sww-Disposition"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The values of DispositionHeader.
|
// The values of DispositionHeader.
|
||||||
@@ -39,15 +39,15 @@ const (
|
|||||||
// headers and body.
|
// headers and body.
|
||||||
DispositionDeny = "deny"
|
DispositionDeny = "deny"
|
||||||
// DispositionClose tells rproxy to close the visitor's connection
|
// DispositionClose tells rproxy to close the visitor's connection
|
||||||
// without an answer, which SWWAF_BAN_RESPONSE asks for when it is close.
|
// without an answer, which SWW_BAN_RESPONSE asks for when it is close.
|
||||||
// servewaf closing the question's connection instead would read as
|
// discriminator closing the question's connection instead would read as
|
||||||
// servewaf being down, and rproxy would then let the request through.
|
// discriminator being down, and rproxy would then let the request through.
|
||||||
DispositionClose = "close"
|
DispositionClose = "close"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReportPath is where rproxy sends the report of each request servewaf
|
// ReportPath is where rproxy sends the report of each request discriminator
|
||||||
// answered.
|
// answered.
|
||||||
const ReportPath = "/_smallwebwaf/report"
|
const ReportPath = "/_sww/report"
|
||||||
|
|
||||||
// QuestionBodyMaxBytes is the most of a visitor's body a question carries.
|
// QuestionBodyMaxBytes is the most of a visitor's body a question carries.
|
||||||
const QuestionBodyMaxBytes = 64 << 10
|
const QuestionBodyMaxBytes = 64 << 10
|
||||||
@@ -76,11 +76,11 @@ const (
|
|||||||
// maxReportedMilliseconds is the longest duration a report may give,
|
// maxReportedMilliseconds is the longest duration a report may give,
|
||||||
// about 31 years, so that it fits a time.Duration.
|
// about 31 years, so that it fits a time.Duration.
|
||||||
maxReportedMilliseconds = 1e12
|
maxReportedMilliseconds = 1e12
|
||||||
// serveWAFTimeout bounds how long a request to servewaf takes to arrive,
|
// discriminatorTimeout bounds how long a request to discriminator takes to
|
||||||
// and its answer to leave, and serveWAFIdleTimeout how long a kept-open
|
// arrive, and its answer to leave, and discriminatorIdleTimeout how long a
|
||||||
// connection waits for its next request.
|
// kept-open connection waits for its next request.
|
||||||
serveWAFTimeout = 60 * time.Second
|
discriminatorTimeout = 60 * time.Second
|
||||||
serveWAFIdleTimeout = 120 * time.Second
|
discriminatorIdleTimeout = 120 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -103,7 +103,7 @@ type Report struct {
|
|||||||
DurationUpstreamTotal float64 `json:"duration_upstream_total"`
|
DurationUpstreamTotal float64 `json:"duration_upstream_total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns the server `smallwebwaf servewaf` 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
|
// 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
|
// 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
|
// visitor. Go's default limit on a request's line and headers, 1 MiB, stays: it
|
||||||
@@ -117,9 +117,9 @@ func New(params Params) *Server {
|
|||||||
Addr: params.Config.ListenAddr,
|
Addr: params.Config.ListenAddr,
|
||||||
Handler: h,
|
Handler: h,
|
||||||
ErrorLog: slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn),
|
ErrorLog: slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn),
|
||||||
ReadTimeout: serveWAFTimeout,
|
ReadTimeout: discriminatorTimeout,
|
||||||
WriteTimeout: serveWAFTimeout,
|
WriteTimeout: discriminatorTimeout,
|
||||||
IdleTimeout: serveWAFIdleTimeout,
|
IdleTimeout: discriminatorIdleTimeout,
|
||||||
},
|
},
|
||||||
Ledger: h.ledger,
|
Ledger: h.ledger,
|
||||||
Limiter: h.limiter,
|
Limiter: h.limiter,
|
||||||
@@ -133,8 +133,9 @@ func New(params Params) *Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeHTTP handles one request to servewaf: a request carrying ClientHeader is
|
// ServeHTTP handles one request to discriminator: a request carrying
|
||||||
// rproxy's question, and any other is for servewaf itself.
|
// ClientHeader is rproxy's question, and any other is for discriminator
|
||||||
|
// itself.
|
||||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
if len(r.Header.Values(ClientHeader)) > 0 {
|
if len(r.Header.Values(ClientHeader)) > 0 {
|
||||||
h.answerQuestion(w, r)
|
h.answerQuestion(w, r)
|
||||||
@@ -148,9 +149,9 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// refusedWhateverTheAnswer reports whether the request is one that does not wait
|
// refusedWhateverTheAnswer reports whether the request is one that does not wait
|
||||||
// for GeoJS, since the checks will refuse it at now whatever GeoJS answers about
|
// for GeoJS, since the checks will refuse it at now whatever GeoJS answers about
|
||||||
// its client: it is over the header limits, a blocklist lists its client while
|
// its client: it is over the header limits, a blocklist lists its client while
|
||||||
// SWWAF_BLOCKLIST_ACTION is deny, the CrowdSec decision list lists it, or it is
|
// SWW_BLOCKLIST_ACTION is deny, the CrowdSec decision list lists it, or it is
|
||||||
// over a whole rate limit. In observe mode, which refuses nothing, none is.
|
// over a whole rate limit. In observe mode, which refuses nothing, none is.
|
||||||
// rproxy lets a request through when servewaf answers too late.
|
// rproxy lets a request through when discriminator answers too late.
|
||||||
func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
|
func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
|
||||||
if rq.h.config.Observe {
|
if rq.h.config.Observe {
|
||||||
return false
|
return false
|
||||||
@@ -165,7 +166,7 @@ func (rq *request) refusedWhateverTheAnswer(now time.Time) bool {
|
|||||||
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now))
|
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now))
|
||||||
}
|
}
|
||||||
|
|
||||||
// overHeaderLimits reports whether servewaf refuses the request for its
|
// overHeaderLimits reports whether discriminator refuses the request for its
|
||||||
// headers: it has more than maxHeaderFields fields, each value of a header sent
|
// headers: it has more than maxHeaderFields fields, each value of a header sent
|
||||||
// twice being one, or more than maxCookies cookies, each part of a Cookie header
|
// twice being one, or more than maxCookies cookies, each part of a Cookie header
|
||||||
// between semicolons being one unless it is empty or only spaces and tabs. The
|
// between semicolons being one unless it is empty or only spaces and tabs. The
|
||||||
@@ -212,7 +213,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer rq.finish()
|
defer rq.finish()
|
||||||
|
|
||||||
rq.site = r.Host
|
rq.site = r.Host
|
||||||
rq.line.ServeWAFLine = requestlog.ServeWAFLineDecision
|
rq.line.DiscriminatorLine = requestlog.DiscriminatorLineDecision
|
||||||
refused := rq.check(r.Context())
|
refused := rq.check(r.Context())
|
||||||
rq.checked = time.Now()
|
rq.checked = time.Now()
|
||||||
underOwnPrefix := strings.HasPrefix(r.URL.Path, adminPrefix)
|
underOwnPrefix := strings.HasPrefix(r.URL.Path, adminPrefix)
|
||||||
@@ -238,7 +239,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The app never sees this prefix. servewaf'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.
|
// own address, so this answers as a path with no endpoint does.
|
||||||
rq.line.Action = requestlog.ActionAdmin
|
rq.line.Action = requestlog.ActionAdmin
|
||||||
rq.out.Header().Set(DispositionHeader, DispositionDeny)
|
rq.out.Header().Set(DispositionHeader, DispositionDeny)
|
||||||
@@ -246,7 +247,7 @@ func (h *handler) answerQuestion(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// readQuestion returns the visitor's address and the body a question carries,
|
// readQuestion returns the visitor's address and the body a question carries,
|
||||||
// and whether servewaf 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
|
// 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
|
// 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
|
// decision yet, so that another question with that id is refused while this
|
||||||
@@ -289,7 +290,7 @@ func (h *handler) readQuestion(
|
|||||||
|
|
||||||
held, _ := h.awaitingReport.ContainsOrAdd(id, nil)
|
held, _ := h.awaitingReport.ContainsOrAdd(id, nil)
|
||||||
if held {
|
if held {
|
||||||
h.refuseQuestion(w, requestIDHeader+" is the id of a request servewaf holds")
|
h.refuseQuestion(w, requestIDHeader+" is the id of a request discriminator holds")
|
||||||
|
|
||||||
return netip.Addr{}, nil, false
|
return netip.Addr{}, nil, false
|
||||||
}
|
}
|
||||||
@@ -297,17 +298,17 @@ func (h *handler) readQuestion(
|
|||||||
return client, body, true
|
return client, body, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// refuseQuestion answers a question servewaf does not take, for reason.
|
// refuseQuestion answers a question discriminator does not take, for reason.
|
||||||
func (h *handler) refuseQuestion(w http.ResponseWriter, reason string) {
|
func (h *handler) refuseQuestion(w http.ResponseWriter, reason string) {
|
||||||
h.processLog.Warn("a question is refused", "reason", reason)
|
h.processLog.Warn("a question is refused", "reason", reason)
|
||||||
http.Error(w, reason, http.StatusBadRequest)
|
http.Error(w, reason, http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// asTheVisitorSentIt returns a copy of r, a question, as the checks and the
|
// 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 servewaf, with
|
// log line are to see it: without the headers rproxy adds for discriminator,
|
||||||
// body, which has been read of r, and with the length the visitor announced,
|
// with body, which has been read of r, and with the length the visitor
|
||||||
// as -1 when it announced none. The question's own length covers only the part
|
// announced, as -1 when it announced none. The question's own length covers
|
||||||
// of the body it carries.
|
// only the part of the body it carries.
|
||||||
func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request {
|
func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request {
|
||||||
sent := r.Clone(r.Context())
|
sent := r.Clone(r.Context())
|
||||||
announced := sent.Header.Get(ContentLengthHeader)
|
announced := sent.Header.Get(ContentLengthHeader)
|
||||||
@@ -334,7 +335,7 @@ func asTheVisitorSentIt(r *http.Request, body []byte) *http.Request {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// allow answers a question with DispositionAllow and, while
|
// allow answers a question with DispositionAllow and, while
|
||||||
// SWWAF_ADD_LOOKUP_HEADERS is set, the client's AS number and country.
|
// SWW_ADD_LOOKUP_HEADERS is set, the client's AS number and country.
|
||||||
func (rq *request) allow() {
|
func (rq *request) allow() {
|
||||||
rq.allowed = true
|
rq.allowed = true
|
||||||
|
|
||||||
@@ -360,7 +361,7 @@ func (rq *request) deny(r refusal) {
|
|||||||
rq.refused = &r
|
rq.refused = &r
|
||||||
|
|
||||||
if r.status == 0 {
|
if r.status == 0 {
|
||||||
// SWWAF_BAN_RESPONSE is close. Written past rq.out, since the log line's
|
// SWW_BAN_RESPONSE is close. Written past rq.out, since the log line's
|
||||||
// status stays 0: the visitor is sent nothing.
|
// status stays 0: the visitor is sent nothing.
|
||||||
rq.out.Header().Set(DispositionHeader, DispositionClose)
|
rq.out.Header().Set(DispositionHeader, DispositionClose)
|
||||||
rq.out.ResponseWriter.WriteHeader(http.StatusOK)
|
rq.out.ResponseWriter.WriteHeader(http.StatusOK)
|
||||||
@@ -372,7 +373,7 @@ func (rq *request) deny(r refusal) {
|
|||||||
http.Error(rq.out, http.StatusText(r.status), r.status)
|
http.Error(rq.out, http.StatusText(r.status), r.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// answerOwnEndpoint answers a request to servewaf'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
|
// runs on it: the address is on the private network, where the peers are
|
||||||
// rproxy and the admin's tools, not visitors.
|
// rproxy and the admin's tools, not visitors.
|
||||||
func (h *handler) answerOwnEndpoint(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) answerOwnEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -487,7 +488,7 @@ func (h *handler) finishWith(held *decision, report Report) {
|
|||||||
requestBytes: report.RequestBytes,
|
requestBytes: report.RequestBytes,
|
||||||
}
|
}
|
||||||
|
|
||||||
rq.line.ServeWAFLine = requestlog.ServeWAFLineReport
|
rq.line.DiscriminatorLine = requestlog.DiscriminatorLineReport
|
||||||
rq.line.Status = report.Status
|
rq.line.Status = report.Status
|
||||||
rq.line.RequestBytes = report.RequestBytes
|
rq.line.RequestBytes = report.RequestBytes
|
||||||
rq.line.ResponseBytes = report.ResponseBytes
|
rq.line.ResponseBytes = report.ResponseBytes
|
||||||
+206
-196
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
"maps"
|
||||||
@@ -10,14 +10,14 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
|
const errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
||||||
|
|
||||||
// refused is a request the tests here send, which servewaf refuses after a rule
|
// refused is a request the tests here send, which discriminator refuses after a rule
|
||||||
// file match.
|
// file match.
|
||||||
type refused int
|
type refused int
|
||||||
|
|
||||||
@@ -48,10 +48,10 @@ func (r refused) send(s *sender, from string) logLine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startForErrorBurst is startWithClock with testRules, the metrics token and
|
// startForErrorBurst is startWithClock with testRules, the metrics token and
|
||||||
// SWWAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
|
// SWW_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
|
||||||
func startForErrorBurst(
|
func startForErrorBurst(
|
||||||
t *testing.T, threshold string, env map[string]string,
|
t *testing.T, threshold string, env map[string]string,
|
||||||
) (*sender, *clock, *servewaf.Server) {
|
) (*sender, *clock, *discriminator.Server) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
@@ -156,12 +156,12 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
|
||||||
`kind="refusals",window="minute"}`, 1)
|
`kind="refusals",window="minute"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1)
|
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 3)
|
`sww_offences_total{instance="app",kind="rule_blocked"}`, 3)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
||||||
@@ -258,15 +258,15 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||||
exempt = "192.0.2.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _, _ := startForErrorBurst(t, "1", map[string]string{
|
s, _, _ := startForErrorBurst(t, "1", map[string]string{
|
||||||
allowNets: allowed, rateLimitExemptNets: exempt,
|
allowNets: allowed, rateLimitExemptNets: exempt,
|
||||||
})
|
})
|
||||||
|
|
||||||
// A client in SWWAF_ALLOW_NETS is not refused, and so not counted.
|
// A client in SWW_ALLOW_NETS is not refused, and so not counted.
|
||||||
for range 3 {
|
for range 3 {
|
||||||
s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward)
|
s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
|
func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
|
||||||
@@ -21,7 +21,7 @@ func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
for range healthChecks {
|
for range healthChecks {
|
||||||
got := s.adminRequest("", http.MethodGet, servewaf.HealthPath, "", http.StatusOK)
|
got := s.adminRequest("", http.MethodGet, discriminator.HealthPath, "", http.StatusOK)
|
||||||
|
|
||||||
if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
|
if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
|
||||||
t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+
|
t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
||||||
@@ -45,7 +45,7 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|||||||
Requests: 4,
|
Requests: 4,
|
||||||
Forwarded: 2,
|
Forwarded: 2,
|
||||||
Refused: 2,
|
Refused: 2,
|
||||||
// The app answers with no body, smallwebwaf with its status text.
|
// The app answers with no body, sww with its status text.
|
||||||
ResponseBytes: 2 * int64(len("Forbidden\n")),
|
ResponseBytes: 2 * int64(len("Forbidden\n")),
|
||||||
Responses: ratelimit.Responses{Status2xx: 2, Status4xx: 2},
|
Responses: ratelimit.Responses{Status2xx: 2, Status4xx: 2},
|
||||||
Offences: ratelimit.Offences{Limit: 1},
|
Offences: ratelimit.Offences{Limit: 1},
|
||||||
@@ -100,8 +100,9 @@ func TestOwnEndpointsAreNotInTheHistory(t *testing.T) {
|
|||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{metricsToken: token})
|
s, _, server := startWithClock(t, "", map[string]string{metricsToken: token})
|
||||||
|
|
||||||
s.adminRequest("", http.MethodGet, servewaf.HealthPath, "", http.StatusOK)
|
s.adminRequest("", http.MethodGet, discriminator.HealthPath, "", http.StatusOK)
|
||||||
s.adminRequest("", http.MethodGet, servewaf.MetricsPath, "", http.StatusUnauthorized)
|
s.adminRequest("", http.MethodGet, discriminator.MetricsPath, "",
|
||||||
|
http.StatusUnauthorized)
|
||||||
s.scrape()
|
s.scrape()
|
||||||
|
|
||||||
if clients := server.Limiter.Snapshot(); len(clients) != 0 {
|
if clients := server.Limiter.Snapshot(); len(clients) != 0 {
|
||||||
@@ -115,7 +116,7 @@ func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) {
|
|||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
|
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
|
||||||
|
|
||||||
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound)
|
wantStatus(t, get(t, addr, "/_sww/nothing"), http.StatusNotFound)
|
||||||
out.endedLine(t)
|
out.endedLine(t)
|
||||||
|
|
||||||
history := historyOf(t, server, localhost)
|
history := historyOf(t, server, localhost)
|
||||||
@@ -126,7 +127,9 @@ func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// historyOf returns the history of the client at addr.
|
// historyOf returns the history of the client at addr.
|
||||||
func historyOf(t *testing.T, server *servewaf.Server, addr string) ratelimit.History {
|
func historyOf(
|
||||||
|
t *testing.T, server *discriminator.Server, addr string,
|
||||||
|
) ratelimit.History {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
client := netip.MustParsePrefix(addr + "/32")
|
client := netip.MustParsePrefix(addr + "/32")
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// The headers in which an answer of allow gives rproxy the client's AS number
|
// The headers in which an answer of allow gives rproxy the client's AS number
|
||||||
// and country, for the app, while SWWAF_ADD_LOOKUP_HEADERS is set. Go writes
|
// and country, for the app, while SWW_ADD_LOOKUP_HEADERS is set. Go writes
|
||||||
// every header name in this form, as it sends it and as it receives it, so
|
// every header name in this form, as it sends it and as it receives it, so
|
||||||
// X-Client-ASN arrives as X-Client-Asn; header names are not case-sensitive.
|
// X-Client-ASN arrives as X-Client-Asn; header names are not case-sensitive.
|
||||||
const (
|
const (
|
||||||
@@ -20,7 +20,7 @@ const (
|
|||||||
|
|
||||||
// lookUp looks up the client's AS number and country, in the lookup
|
// lookUp looks up the client's AS number and country, in the lookup
|
||||||
// database or through GeoJS, and notes them for the log line, unless
|
// database or through GeoJS, and notes them for the log line, unless
|
||||||
// SWWAF_LOOKUP_SOURCE is off or the client is on a private, loopback or
|
// SWW_LOOKUP_SOURCE is off or the client is on a private, loopback or
|
||||||
// link-local address, which no lookup can place. The lookup database
|
// link-local address, which no lookup can place. The lookup database
|
||||||
// answers at once. With GeoJS, while a setting needs the answer, such as a
|
// answers at once. With GeoJS, while a setting needs the answer, such as a
|
||||||
// country list or a biased threshold, a new client's request waits for it,
|
// country list or a biased threshold, a new client's request waits for it,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
// and country.
|
// and country.
|
||||||
type asnAndCountry struct{ asn, asName, country string }
|
type asnAndCountry struct{ asn, asName, country string }
|
||||||
|
|
||||||
// fileSource is the SWWAF_LOOKUP_SOURCE that looks clients up in the
|
// fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the
|
||||||
// lookup database.
|
// lookup database.
|
||||||
const fileSource = "file"
|
const fileSource = "file"
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(t *testing.
|
|||||||
addr, out, server, queue := startProxyWithAlerts(t, app.URL, geojsURL, clk.Now,
|
addr, out, server, queue := startProxyWithAlerts(t, app.URL, geojsURL, clk.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
alertWebhookURL: "https://alerts.example/smallwebwaf",
|
alertWebhookURL: "https://alerts.example/sww",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
s := &sender{t: t, addr: addr, out: out}
|
s := &sender{t: t, addr: addr, out: out}
|
||||||
@@ -379,13 +379,13 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
|
|||||||
// clock of the test's own: the wait lasts exactly as long as it should,
|
// 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
|
// 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
|
// network, which would keep that clock from moving on: the request is
|
||||||
// handed to servewaf'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) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
// Not the default. The exclusive list needs the answer, and the app
|
// Not the default. The exclusive list needs the answer, and the app
|
||||||
// is never reached.
|
// is never reached.
|
||||||
const timeout = 3 * time.Second
|
const timeout = 3 * time.Second
|
||||||
|
|
||||||
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now,
|
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
lookupTimeout: timeout.String(),
|
lookupTimeout: timeout.String(),
|
||||||
allowedCountries: "DE",
|
allowedCountries: "DE",
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
@@ -11,15 +11,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||||
metricsTopN = "SWWAF_METRICS_TOP_N"
|
metricsTopN = "SWW_METRICS_TOP_N"
|
||||||
// token is the SWWAF_METRICS_TOKEN the tests set, and bearer how a
|
// token is the SWW_METRICS_TOKEN the tests set, and bearer how a
|
||||||
// request carries it.
|
// request carries it.
|
||||||
token = "0123456789abcdef0123456789abcdef"
|
token = "0123456789abcdef0123456789abcdef"
|
||||||
bearer = "Bearer " + token
|
bearer = "Bearer " + token
|
||||||
@@ -32,7 +32,7 @@ func TestMetricsAreOffWhileTheTokenIsUnset(t *testing.T) {
|
|||||||
|
|
||||||
// An empty token does not match the unset one either.
|
// An empty token does not match the unset one either.
|
||||||
for _, authorization := range []string{bearer, "Bearer ", ""} {
|
for _, authorization := range []string{bearer, "Bearer ", ""} {
|
||||||
s.adminRequest(authorization, http.MethodGet, servewaf.MetricsPath, "",
|
s.adminRequest(authorization, http.MethodGet, discriminator.MetricsPath, "",
|
||||||
http.StatusNotFound)
|
http.StatusNotFound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,18 +46,19 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
|
|||||||
method, path, authorization string
|
method, path, authorization string
|
||||||
status int
|
status int
|
||||||
}{
|
}{
|
||||||
{http.MethodGet, servewaf.MetricsPath, "", http.StatusUnauthorized},
|
{http.MethodGet, discriminator.MetricsPath, "", http.StatusUnauthorized},
|
||||||
{
|
{
|
||||||
http.MethodGet, servewaf.MetricsPath, "Bearer " + strings.ToUpper(token),
|
http.MethodGet, discriminator.MetricsPath, "Bearer " + strings.ToUpper(token),
|
||||||
http.StatusUnauthorized,
|
http.StatusUnauthorized,
|
||||||
},
|
},
|
||||||
{http.MethodGet, servewaf.MetricsPath, "Basic " + token, http.StatusUnauthorized},
|
{http.MethodGet, discriminator.MetricsPath, "Basic " + token,
|
||||||
{http.MethodGet, servewaf.MetricsPath, bearer, http.StatusOK},
|
http.StatusUnauthorized},
|
||||||
{http.MethodGet, servewaf.MetricsPath, "bearer " + token, http.StatusOK},
|
{http.MethodGet, discriminator.MetricsPath, bearer, http.StatusOK},
|
||||||
{http.MethodPost, servewaf.MetricsPath, bearer, http.StatusNotFound},
|
{http.MethodGet, discriminator.MetricsPath, "bearer " + token, http.StatusOK},
|
||||||
{http.MethodGet, servewaf.MetricsPath + "/", bearer, http.StatusNotFound},
|
{http.MethodPost, discriminator.MetricsPath, bearer, http.StatusNotFound},
|
||||||
{http.MethodGet, "/_smallwebwaf/bans", bearer, http.StatusNotFound},
|
{http.MethodGet, discriminator.MetricsPath + "/", bearer, http.StatusNotFound},
|
||||||
{http.MethodPost, servewaf.HealthPath, "", http.StatusNotFound},
|
{http.MethodGet, "/_sww/bans", bearer, http.StatusNotFound},
|
||||||
|
{http.MethodPost, discriminator.HealthPath, "", http.StatusNotFound},
|
||||||
} {
|
} {
|
||||||
got := s.adminRequest(tc.authorization, tc.method, tc.path, "", tc.status)
|
got := s.adminRequest(tc.authorization, tc.method, tc.path, "", tc.status)
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if tc.status == http.StatusOK &&
|
if tc.status == http.StatusOK &&
|
||||||
!strings.Contains(string(got.body), "# TYPE smallwebwaf_requests_total counter") {
|
!strings.Contains(string(got.body), "# TYPE sww_requests_total counter") {
|
||||||
t.Errorf("the metrics are\n%s", got.body)
|
t.Errorf("the metrics are\n%s", got.body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,7 +87,7 @@ func TestMetricsCountTheTraffic(t *testing.T) {
|
|||||||
|
|
||||||
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
|
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
|
||||||
wantStatus(t, got, http.StatusOK)
|
wantStatus(t, got, http.StatusOK)
|
||||||
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound)
|
wantStatus(t, get(t, addr, "/_sww/nothing"), http.StatusNotFound)
|
||||||
out.endedLines(t, 2)
|
out.endedLines(t, 2)
|
||||||
|
|
||||||
forward := `{action="forward",instance="app",status_class="2xx"}`
|
forward := `{action="forward",instance="app",status_class="2xx"}`
|
||||||
@@ -94,17 +95,17 @@ func TestMetricsCountTheTraffic(t *testing.T) {
|
|||||||
|
|
||||||
// The request for the metrics is itself under way.
|
// The request for the metrics is itself under way.
|
||||||
metrics := scrape(t, server.Addr)
|
metrics := scrape(t, server.Addr)
|
||||||
wantMetric(t, metrics, "smallwebwaf_requests_total"+forward, 1)
|
wantMetric(t, metrics, "sww_requests_total"+forward, 1)
|
||||||
wantMetric(t, metrics, "smallwebwaf_requests_total"+notFound, 1)
|
wantMetric(t, metrics, "sww_requests_total"+notFound, 1)
|
||||||
wantMetric(t, metrics, "smallwebwaf_request_bytes_total"+forward, 3)
|
wantMetric(t, metrics, "sww_request_bytes_total"+forward, 3)
|
||||||
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+forward, 5)
|
wantMetric(t, metrics, "sww_response_bytes_total"+forward, 5)
|
||||||
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+notFound,
|
wantMetric(t, metrics, "sww_response_bytes_total"+notFound,
|
||||||
float64(len("Not Found\n")))
|
float64(len("Not Found\n")))
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_request_duration_seconds_count{instance="app"}`, 2)
|
`sww_request_duration_seconds_count{instance="app"}`, 2)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_upstream_duration_seconds_count{instance="app"}`, 1)
|
`sww_upstream_duration_seconds_count{instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_requests_in_flight{instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_requests_in_flight{instance="app"}`, 1)
|
||||||
metric(t, metrics, `go_goroutines{instance="app"}`)
|
metric(t, metrics, `go_goroutines{instance="app"}`)
|
||||||
metric(t, metrics, `process_start_time_seconds{instance="app"}`)
|
metric(t, metrics, `process_start_time_seconds{instance="app"}`)
|
||||||
}
|
}
|
||||||
@@ -112,7 +113,7 @@ func TestMetricsCountTheTraffic(t *testing.T) {
|
|||||||
func TestMetricsCountLimitsAndBans(t *testing.T) {
|
func TestMetricsCountLimitsAndBans(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
|
|
||||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
@@ -123,7 +124,7 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
|||||||
maxBanDuration: "2h",
|
maxBanDuration: "2h",
|
||||||
})
|
})
|
||||||
|
|
||||||
// SWWAF_BAN_RESPONSE=close sends no status at all.
|
// SWW_BAN_RESPONSE=close sends no status at all.
|
||||||
s.get(denied, 0, requestlog.ActionDenied)
|
s.get(denied, 0, requestlog.ActionDenied)
|
||||||
|
|
||||||
// A first broken limit bans for an hour.
|
// A first broken limit bans for an hour.
|
||||||
@@ -132,31 +133,31 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
|||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_requests_total{action="denied",instance="app",status_class="none"}`, 1)
|
`sww_requests_total{action="denied",instance="app",status_class="none"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
|
||||||
`kind="requests",window="minute"}`, 1)
|
`kind="requests",window="minute"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1)
|
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_active_bans{instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 0)
|
wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 0)
|
||||||
|
|
||||||
clk.advance(time.Hour)
|
clk.advance(time.Hour)
|
||||||
wantMetric(t, s.scrape(), `smallwebwaf_active_bans{instance="app"}`, 0)
|
wantMetric(t, s.scrape(), `sww_active_bans{instance="app"}`, 0)
|
||||||
|
|
||||||
// A limit broken again right after would ban for three hours, longer
|
// A limit broken again right after would ban for three hours, longer
|
||||||
// than SWWAF_MAX_BAN_DURATION, so the ban is permanent.
|
// than SWW_MAX_BAN_DURATION, so the ban is permanent.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, 0, requestlog.ActionRateLimited)
|
s.get(client, 0, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
metrics = s.scrape()
|
metrics = s.scrape()
|
||||||
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
|
||||||
`kind="requests",window="minute"}`, 2)
|
`kind="requests",window="minute"}`, 2)
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 2)
|
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 2)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 2)
|
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 2)
|
||||||
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_active_bans{instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 1)
|
||||||
// denied and client.
|
// denied and client.
|
||||||
wantMetric(t, metrics, `smallwebwaf_tracked_clients{instance="app"}`, 2)
|
wantMetric(t, metrics, `sww_tracked_clients{instance="app"}`, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
||||||
@@ -167,7 +168,7 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
|||||||
adminToken: adminSecret,
|
adminToken: adminSecret,
|
||||||
})
|
})
|
||||||
|
|
||||||
const admins = `smallwebwaf_bans_made_total{cause="admin",instance="app"}`
|
const admins = `sww_bans_made_total{cause="admin",instance="app"}`
|
||||||
|
|
||||||
wantMetric(t, s.scrape(), admins, 0)
|
wantMetric(t, s.scrape(), admins, 0)
|
||||||
|
|
||||||
@@ -180,7 +181,7 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
|||||||
wantMetric(t, s.scrape(), admins, 1)
|
wantMetric(t, s.scrape(), admins, 1)
|
||||||
|
|
||||||
// And a ban made through the endpoint.
|
// And a ban made through the endpoint.
|
||||||
s.admin(http.MethodPost, servewaf.BansPath, banOtherClient, http.StatusOK)
|
s.admin(http.MethodPost, discriminator.BansPath, banOtherClient, http.StatusOK)
|
||||||
wantMetric(t, s.scrape(), admins, 2)
|
wantMetric(t, s.scrape(), admins, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,25 +234,25 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
|||||||
lines++
|
lines++
|
||||||
|
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3)
|
`sww_country_requests_total{country="KP",instance="app"}`, 3)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_requests_total{country="DE",instance="app"}`, 2)
|
`sww_country_requests_total{country="DE",instance="app"}`, 2)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_requests_total{country="other",instance="app"}`, 1)
|
`sww_country_requests_total{country="other",instance="app"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_list_refusals_total{country="KP",instance="app"}`, 3)
|
`sww_country_list_refusals_total{country="KP",instance="app"}`, 3)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_request_bytes_total{country="KP",instance="app"}`, 0)
|
`sww_country_request_bytes_total{country="KP",instance="app"}`, 0)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_request_bytes_total{country="DE",instance="app"}`, 6)
|
`sww_country_request_bytes_total{country="DE",instance="app"}`, 6)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_response_bytes_total{country="KP",instance="app"}`,
|
`sww_country_response_bytes_total{country="KP",instance="app"}`,
|
||||||
float64(3*len("Forbidden\n")))
|
float64(3*len("Forbidden\n")))
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_response_bytes_total{country="other",instance="app"}`,
|
`sww_country_response_bytes_total{country="other",instance="app"}`,
|
||||||
float64(len("hello")))
|
float64(len("hello")))
|
||||||
wantNoSeries(t, metrics,
|
wantNoSeries(t, metrics,
|
||||||
`smallwebwaf_country_requests_total{country="FR",instance="app"}`)
|
`sww_country_requests_total{country="FR",instance="app"}`)
|
||||||
|
|
||||||
// Once FR is busier than DE, it takes DE's place: its series counts
|
// Once FR is busier than DE, it takes DE's place: its series counts
|
||||||
// from then on, and DE's is gone.
|
// from then on, and DE's is gone.
|
||||||
@@ -259,15 +260,15 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
|||||||
|
|
||||||
metrics = scrape(t, server.Addr)
|
metrics = scrape(t, server.Addr)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3)
|
`sww_country_requests_total{country="KP",instance="app"}`, 3)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_requests_total{country="FR",instance="app"}`, 2)
|
`sww_country_requests_total{country="FR",instance="app"}`, 2)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_country_requests_total{country="other",instance="app"}`, 2)
|
`sww_country_requests_total{country="other",instance="app"}`, 2)
|
||||||
wantNoSeries(t, metrics,
|
wantNoSeries(t, metrics,
|
||||||
`smallwebwaf_country_requests_total{country="DE",instance="app"}`)
|
`sww_country_requests_total{country="DE",instance="app"}`)
|
||||||
wantNoSeries(t, metrics,
|
wantNoSeries(t, metrics,
|
||||||
`smallwebwaf_country_request_bytes_total{country="DE",instance="app"}`)
|
`sww_country_request_bytes_total{country="DE",instance="app"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
||||||
@@ -298,15 +299,15 @@ func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
|||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_asn_requests_total{asn="AS64501",instance="app"}`, 2)
|
`sww_asn_requests_total{asn="AS64501",instance="app"}`, 2)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_asn_requests_total{asn="other",instance="app"}`, 1)
|
`sww_asn_requests_total{asn="other",instance="app"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_asn_request_bytes_total{asn="AS64501",instance="app"}`, 0)
|
`sww_asn_request_bytes_total{asn="AS64501",instance="app"}`, 0)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_asn_response_bytes_total{asn="other",instance="app"}`, 0)
|
`sww_asn_response_bytes_total{asn="other",instance="app"}`, 0)
|
||||||
wantNoSeries(t, metrics,
|
wantNoSeries(t, metrics,
|
||||||
`smallwebwaf_asn_requests_total{asn="AS64502",instance="app"}`)
|
`sww_asn_requests_total{asn="AS64502",instance="app"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
||||||
@@ -327,26 +328,26 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// GeoJS fails, so the client counts as coming from an unknown country,
|
// GeoJS fails, so the client counts as coming from an unknown country,
|
||||||
// which SWWAF_DENIED_COUNTRIES does not refuse.
|
// which SWW_DENIED_COUNTRIES does not refuse.
|
||||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||||
req.Header.Set(forwardedFor, fromDE)
|
req.Header.Set(forwardedFor, fromDE)
|
||||||
wantStatus(t, do(t, req), http.StatusOK)
|
wantStatus(t, do(t, req), http.StatusOK)
|
||||||
|
|
||||||
// The client stops waiting for GeoJS once SWWAF_LOOKUP_TIMEOUT is up, so
|
// The client stops waiting for GeoJS once SWW_LOOKUP_TIMEOUT is up, so
|
||||||
// GeoJS's failure can come after its request has ended.
|
// GeoJS's failure can come after its request has ended.
|
||||||
deadline := time.Now().Add(waitLimit)
|
deadline := time.Now().Add(waitLimit)
|
||||||
metrics := scrape(t, server.Addr)
|
metrics := scrape(t, server.Addr)
|
||||||
|
|
||||||
for metric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`) == 0 &&
|
for metric(t, metrics, `sww_geojs_failures_total{instance="app"}`) == 0 &&
|
||||||
time.Now().Before(deadline) {
|
time.Now().Before(deadline) {
|
||||||
time.Sleep(pollInterval)
|
time.Sleep(pollInterval)
|
||||||
|
|
||||||
metrics = scrape(t, server.Addr)
|
metrics = scrape(t, server.Addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantMetric(t, metrics, `smallwebwaf_geojs_requests_total{instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_geojs_requests_total{instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_geojs_failures_total{instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_geojs_unanswered_total{instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_geojs_unanswered_total{instance="app"}`, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// keptAnswer returns GeoJS's answer that the client at addr is in
|
// keptAnswer returns GeoJS's answer that the client at addr is in
|
||||||
@@ -360,12 +361,12 @@ func keptAnswer(addr, country string) lookup.Answer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// scrape asks servewaf at addr for the metrics, with the token, and returns
|
// scrape asks discriminator at addr for the metrics, with the token, and returns
|
||||||
// them.
|
// them.
|
||||||
func scrape(t *testing.T, addr string) string {
|
func scrape(t *testing.T, addr string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := newRequest(t, http.MethodGet, addr, servewaf.MetricsPath, http.NoBody)
|
req := newRequest(t, http.MethodGet, addr, discriminator.MetricsPath, http.NoBody)
|
||||||
req.Header.Set("Authorization", bearer)
|
req.Header.Set("Authorization", bearer)
|
||||||
|
|
||||||
got := do(t, req)
|
got := do(t, req)
|
||||||
@@ -380,14 +381,14 @@ func scrape(t *testing.T, addr string) string {
|
|||||||
func (s *sender) scrape() string {
|
func (s *sender) scrape() string {
|
||||||
s.t.Helper()
|
s.t.Helper()
|
||||||
|
|
||||||
return string(s.adminRequest(bearer, http.MethodGet, servewaf.MetricsPath, "",
|
return string(s.adminRequest(bearer, http.MethodGet, discriminator.MetricsPath, "",
|
||||||
http.StatusOK).body)
|
http.StatusOK).body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// metric returns the value of series in metrics, which are in the
|
// metric returns the value of series in metrics, which are in the
|
||||||
// Prometheus text format. series is a name and its labels in the order of
|
// Prometheus text format. series is a name and its labels in the order of
|
||||||
// their names, such as
|
// their names, such as
|
||||||
// smallwebwaf_offences_total{instance="app",kind="limit"}. It fails the
|
// sww_offences_total{instance="app",kind="limit"}. It fails the
|
||||||
// test if there is no such series.
|
// test if there is no such series.
|
||||||
func metric(t *testing.T, metrics, series string) float64 {
|
func metric(t *testing.T, metrics, series string) float64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,19 +11,19 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// observe is the value of SWWAF_MODE for observe mode.
|
// observe is the value of SWW_MODE for observe mode.
|
||||||
const observe = "observe"
|
const observe = "observe"
|
||||||
|
|
||||||
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
banned = otherClient // under a ban read from bans.json
|
banned = otherClient // under a ban read from bans.json
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting string // "" leaves SWWAF_MODE at its default
|
setting string // "" leaves SWW_MODE at its default
|
||||||
observe bool
|
observe bool
|
||||||
}{
|
}{
|
||||||
{"", false},
|
{"", false},
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
// Package servewaf is the server the servewaf command runs. It answers rproxy's
|
// Package discriminator is the server the discriminator command runs. It answers
|
||||||
// questions, takes rproxy's reports and answers its own endpoints, and writes a
|
// rproxy's questions, takes rproxy's reports and answers its own endpoints, and
|
||||||
// log line for each.
|
// writes a log line for each.
|
||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -23,43 +24,47 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminPrefix starts the path of each of servewaf's own endpoints. The app never
|
// adminPrefix starts the path of each of discriminator's own endpoints. The app never
|
||||||
// sees a path under it.
|
// sees a path under it.
|
||||||
const adminPrefix = "/_smallwebwaf/"
|
const adminPrefix = "/_sww/"
|
||||||
|
|
||||||
// HealthPath is the health endpoint of servewaf and of rproxy, which the
|
// HealthPath is the health endpoint of discriminator and of rproxy, which the
|
||||||
// container's health check asks.
|
// container's health check asks.
|
||||||
const HealthPath = "/_smallwebwaf/healthz"
|
const HealthPath = "/_sww/healthz"
|
||||||
|
|
||||||
// MetricsPath is where the metrics are, for a request that carries
|
// MetricsPath is where the metrics are, for a request that carries
|
||||||
// SWWAF_METRICS_TOKEN.
|
// SWW_METRICS_TOKEN.
|
||||||
const MetricsPath = "/_smallwebwaf/metrics"
|
const MetricsPath = "/_sww/metrics"
|
||||||
|
|
||||||
// BansPath is where an admin lists and adds bans, and, followed by / and
|
// BansPath is where an admin lists and adds bans, and, followed by / and
|
||||||
// a client's address, lifts them, with SWWAF_ADMIN_TOKEN.
|
// a client's address, lifts them, with SWW_ADMIN_TOKEN.
|
||||||
const BansPath = "/_smallwebwaf/bans"
|
const BansPath = "/_sww/bans"
|
||||||
|
|
||||||
// ClientsPath is where an admin asks what servewaf knows of a client, by the
|
// ClientsPath is where an admin asks what discriminator knows of a client, by
|
||||||
// client's address after it, with SWWAF_ADMIN_TOKEN.
|
// the client's address after it, with SWW_ADMIN_TOKEN.
|
||||||
const ClientsPath = "/_smallwebwaf/clients/"
|
const ClientsPath = "/_sww/clients/"
|
||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
// RequestLog receives two JSON lines per question, one at its decision and
|
// RequestLog receives two JSON lines per question, one at its decision and
|
||||||
// one when its report comes, and one per request to servewaf's own endpoints.
|
// one when its report comes, and one per request to discriminator's own
|
||||||
|
// endpoints.
|
||||||
RequestLog io.Writer
|
RequestLog io.Writer
|
||||||
// ProcessLog receives the process's own messages.
|
// ProcessLog receives the process's own messages.
|
||||||
ProcessLog *slog.Logger
|
ProcessLog *slog.Logger
|
||||||
// GeoJSURL is where clients' AS numbers and countries are looked up
|
// GeoJSURL is where clients' AS numbers and countries are looked up
|
||||||
// while SWWAF_LOOKUP_SOURCE is geojs, normally lookup.URL.
|
// while SWW_LOOKUP_SOURCE is geojs, normally lookup.URL.
|
||||||
GeoJSURL string
|
GeoJSURL string
|
||||||
// AbuseIPDBURL is where clients are checked with AbuseIPDB while
|
// AbuseIPDBURL is where clients are checked with AbuseIPDB while
|
||||||
// SWWAF_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL.
|
// SWW_ABUSEIPDB_KEY is set, normally reputation.AbuseIPDBURL.
|
||||||
AbuseIPDBURL string
|
AbuseIPDBURL string
|
||||||
// LookupFile is the lookup database they are looked up in while
|
// LookupFile is the lookup database they are looked up in while
|
||||||
// SWWAF_LOOKUP_SOURCE is file, and nil otherwise.
|
// SWW_LOOKUP_SOURCE is file, and nil otherwise.
|
||||||
LookupFile *lookup.File
|
LookupFile *lookup.File
|
||||||
|
// CoreRuleSet is what NewCoreRuleSet returns: what inspects requests
|
||||||
|
// while SWW_WAF_MODE is not off, and nil while it is.
|
||||||
|
CoreRuleSet *waf.CoreRuleSet
|
||||||
// Now tells the time by which requests are counted for the rate
|
// Now tells the time by which requests are counted for the rate
|
||||||
// limits, bans are made and run out, and GeoJS's answers are kept,
|
// limits, bans are made and run out, and GeoJS's answers are kept,
|
||||||
// normally time.Now in UTC, the time the state files give.
|
// normally time.Now in UTC, the time the state files give.
|
||||||
@@ -67,18 +72,18 @@ type Params struct {
|
|||||||
// Rules are the rule files' rules, which each request is checked
|
// Rules are the rule files' rules, which each request is checked
|
||||||
// against.
|
// against.
|
||||||
Rules *rules.Files
|
Rules *rules.Files
|
||||||
// Alerts receive the alert for each ban servewaf 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
|
// permanent, for each count over an anomaly threshold, for each request
|
||||||
// whose client a blocklist, the CrowdSec decision list, a DNSBL zone or
|
// 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
|
// AbuseIPDB lists, for each request the Core Rule Set scores at or over
|
||||||
// SWWAF_WAF_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list
|
// SWW_WAF_ANOMALY_THRESHOLD, and for GeoJS failing, a fetch of a list
|
||||||
// failing, a query to a DNSBL zone or a check with AbuseIPDB failing,
|
// failing, a query to a DNSBL zone or a check with AbuseIPDB failing,
|
||||||
// or the day's AbuseIPDB checks used up.
|
// or the day's AbuseIPDB checks used up.
|
||||||
Alerts *alerts.Queue
|
Alerts *alerts.Queue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the server servewaf 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 SWWAF_LOOKUP_SOURCE is file, the
|
// 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,
|
// lists fetched from URLs, which its Run fetches, the DNSBL zones' verdicts,
|
||||||
// AbuseIPDB's scores and checks spent, and the metrics.
|
// AbuseIPDB's scores and checks spent, and the metrics.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
@@ -144,7 +149,7 @@ func newHandler(params Params) *handler {
|
|||||||
dnsbl: dnsbl,
|
dnsbl: dnsbl,
|
||||||
abuseIPDB: abuseIPDB,
|
abuseIPDB: abuseIPDB,
|
||||||
rules: params.Rules,
|
rules: params.Rules,
|
||||||
coreRuleSet: newCoreRuleSet(params.Config),
|
coreRuleSet: params.CoreRuleSet,
|
||||||
alerts: params.Alerts,
|
alerts: params.Alerts,
|
||||||
}
|
}
|
||||||
h.geojs = lookup.New(lookup.Params{
|
h.geojs = lookup.New(lookup.Params{
|
||||||
@@ -170,7 +175,7 @@ func newHandler(params Params) *handler {
|
|||||||
// newReputation returns the lists fetched from URLs, the DNSBL zones'
|
// newReputation returns the lists fetched from URLs, the DNSBL zones'
|
||||||
// verdicts and AbuseIPDB's scores, as the settings in params name them,
|
// verdicts and AbuseIPDB's scores, as the settings in params name them,
|
||||||
// with none fetched, asked for or checked yet, and adds their metrics to
|
// with none fetched, asked for or checked yet, and adds their metrics to
|
||||||
// m, AbuseIPDB's while SWWAF_ABUSEIPDB_KEY is set.
|
// m, AbuseIPDB's while SWW_ABUSEIPDB_KEY is set.
|
||||||
func newReputation(
|
func newReputation(
|
||||||
params Params, m *metrics.Metrics,
|
params Params, m *metrics.Metrics,
|
||||||
) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) {
|
) (*reputation.Lists, *reputation.DNSBL, *reputation.AbuseIPDB) {
|
||||||
@@ -202,32 +207,37 @@ func newReputation(
|
|||||||
return lists, dnsbl, abuseIPDB
|
return lists, dnsbl, abuseIPDB
|
||||||
}
|
}
|
||||||
|
|
||||||
// newCoreRuleSet returns the Core Rule Set at SWWAF_WAF_PARANOIA_LEVEL,
|
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
|
||||||
// without the rules SWWAF_WAF_DISABLED_RULES switches off, reading bodies
|
// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
|
||||||
// up to SWWAF_WAF_BODY_LIMIT, or nil while SWWAF_WAF_MODE is off. It reads at
|
// up to SWW_WAF_BODY_LIMIT, with the exclusions in
|
||||||
// most QuestionBodyMaxBytes less one byte: a question's body of
|
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWW_WAF_MODE is off. It
|
||||||
|
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
|
||||||
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
|
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
|
||||||
// JSON or XML body over its limit.
|
// JSON or XML body over its limit. An exclusions file that waf.New refuses is
|
||||||
func newCoreRuleSet(cfg *config.Config) *waf.CoreRuleSet {
|
// an error in every mode. While the mode is off, a set file is loaded with the
|
||||||
if cfg.WAFMode == config.WAFModeOff {
|
// Core Rule Set only to check it.
|
||||||
return nil
|
func NewCoreRuleSet(cfg *config.Config) (*waf.CoreRuleSet, error) {
|
||||||
|
if cfg.WAFMode == config.WAFModeOff && cfg.CoreRuleSetExclusionsFile == "" {
|
||||||
|
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
|
||||||
}
|
}
|
||||||
|
|
||||||
coreRuleSet, err := waf.New(waf.Params{
|
coreRuleSet, err := waf.New(waf.Params{
|
||||||
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
||||||
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
||||||
|
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The Core Rule Set is built in, and the settings cannot break it:
|
return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
|
||||||
// the paranoia level is from 1 to 4, and the id of no rule switches
|
|
||||||
// nothing off.
|
|
||||||
panic(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return coreRuleSet
|
if cfg.WAFMode == config.WAFModeOff {
|
||||||
|
return nil, nil //nolint:nilnil // off is no Core Rule Set, and no error
|
||||||
}
|
}
|
||||||
|
|
||||||
// handler is servewaf's handler. It holds what every request shares; what
|
return coreRuleSet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handler is discriminator's handler. It holds what every request shares; what
|
||||||
// belongs to one request is in a request.
|
// belongs to one request is in a request.
|
||||||
type handler struct {
|
type handler struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -17,11 +17,11 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -55,33 +55,33 @@ const (
|
|||||||
|
|
||||||
// The settings the tests set.
|
// The settings the tests set.
|
||||||
const (
|
const (
|
||||||
mode = "SWWAF_MODE"
|
mode = "SWW_MODE"
|
||||||
trustedProxies = "SWRP_TRUSTED_PROXIES"
|
trustedProxies = "SWRP_TRUSTED_PROXIES"
|
||||||
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
|
ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX"
|
||||||
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
|
maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS"
|
||||||
allowNets = "SWWAF_ALLOW_NETS"
|
allowNets = "SWW_ALLOW_NETS"
|
||||||
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
|
||||||
denyNets = "SWWAF_DENY_NETS"
|
denyNets = "SWW_DENY_NETS"
|
||||||
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE"
|
||||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
|
||||||
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS"
|
||||||
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
lookupSource = "SWW_LOOKUP_SOURCE"
|
||||||
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
|
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
||||||
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
||||||
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS"
|
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
||||||
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
deniedCountries = "SWW_DENIED_COUNTRIES"
|
||||||
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||||
banResponse = "SWWAF_BAN_RESPONSE"
|
banResponse = "SWW_BAN_RESPONSE"
|
||||||
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
|
||||||
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
|
||||||
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
maxBanDuration = "SWW_MAX_BAN_DURATION"
|
||||||
maxBans = "SWWAF_MAX_BANS"
|
maxBans = "SWW_MAX_BANS"
|
||||||
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
|
||||||
instanceName = "SWWAF_INSTANCE_NAME"
|
instanceName = "SWW_INSTANCE_NAME"
|
||||||
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
|
||||||
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
attackBanDuration = "SWW_ATTACK_BAN_DURATION"
|
||||||
rulesDir = "SWWAF_RULES_DIR"
|
rulesDir = "SWW_RULES_DIR"
|
||||||
wafMode = "SWWAF_WAF_MODE"
|
wafMode = "SWW_WAF_MODE"
|
||||||
)
|
)
|
||||||
|
|
||||||
// off is the value that switches a setting off.
|
// off is the value that switches a setting off.
|
||||||
@@ -90,13 +90,13 @@ const off = "off"
|
|||||||
// on is the value that switches a setting that takes true or false on.
|
// on is the value that switches a setting that takes true or false on.
|
||||||
const on = "true"
|
const on = "true"
|
||||||
|
|
||||||
// output collects what smallwebwaf writes on stdout.
|
// output collects what sww writes on stdout.
|
||||||
type output struct {
|
type output struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
buf bytes.Buffer
|
buf bytes.Buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write adds lines smallwebwaf writes.
|
// Write adds lines sww writes.
|
||||||
func (o *output) Write(p []byte) (int, error) {
|
func (o *output) Write(p []byte) (int, error) {
|
||||||
o.mu.Lock()
|
o.mu.Lock()
|
||||||
defer o.mu.Unlock()
|
defer o.mu.Unlock()
|
||||||
@@ -175,9 +175,9 @@ func (o *output) requestLine(t *testing.T) logLine {
|
|||||||
return o.requestLines(t, 1)[0]
|
return o.requestLines(t, 1)[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// endedLines waits for count requests that went through rproxy, or to servewaf'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
|
// own endpoints, to have ended, and returns the last log line of each, in the
|
||||||
// order servewaf 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 {
|
func (o *output) endedLines(t *testing.T, count int) []logLine {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ func (o *output) endedLines(t *testing.T, count int) []logLine {
|
|||||||
|
|
||||||
ended := len(found) >= count
|
ended := len(found) >= count
|
||||||
for _, line := range found[:min(count, len(found))] {
|
for _, line := range found[:min(count, len(found))] {
|
||||||
if line.ServeWAFLine == requestlog.ServeWAFLineDecision {
|
if line.DiscriminatorLine == requestlog.DiscriminatorLineDecision {
|
||||||
ended = false
|
ended = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,7 +205,7 @@ func (o *output) endedLines(t *testing.T, count int) []logLine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// lastLines returns the last request log line written so far of each request,
|
// lastLines returns the last request log line written so far of each request,
|
||||||
// in the order servewaf took them.
|
// in the order discriminator took them.
|
||||||
func (o *output) lastLines(t *testing.T) []logLine {
|
func (o *output) lastLines(t *testing.T) []logLine {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -271,9 +271,9 @@ func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
|
|||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
// startProxy starts servewaf, 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
|
// the settings in env on top of the defaults, and returns where rproxy listens
|
||||||
// and what servewaf writes.
|
// and what discriminator writes.
|
||||||
func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) {
|
func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -293,14 +293,14 @@ func startProxyWithGeoJS(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
||||||
// bans made by the time now tells, and returns servewaf's server as well, whose
|
// bans made by the time now tells, and returns discriminator's server as well, whose
|
||||||
// Addr is where servewaf listens. Unless env sets SWWAF_RULES_DIR, it is an empty
|
// Addr is where discriminator listens. Unless env sets SWW_RULES_DIR, it is an empty
|
||||||
// directory, of no rules, and unless it sets SWWAF_INSTANCE_NAME, that is app,
|
// directory, of no rules, and unless it sets SWW_INSTANCE_NAME, that is app,
|
||||||
// the label instance of every metric.
|
// the label instance of every metric.
|
||||||
func startProxyWithClock(
|
func startProxyWithClock(
|
||||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||||
env map[string]string,
|
env map[string]string,
|
||||||
) (string, *output, *servewaf.Server) {
|
) (string, *output, *discriminator.Server) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env)
|
addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env)
|
||||||
@@ -309,14 +309,14 @@ func startProxyWithClock(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startProxyWithAlerts is startProxyWithClock, and returns the queue of
|
// startProxyWithAlerts is startProxyWithClock, and returns the queue of
|
||||||
// the alerts servewaf raises as well, as newServeWAF makes them.
|
// the alerts discriminator raises as well, as newDiscriminator makes them.
|
||||||
func startProxyWithAlerts(
|
func startProxyWithAlerts(
|
||||||
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
||||||
env map[string]string,
|
env map[string]string,
|
||||||
) (string, *output, *servewaf.Server, *alerts.Queue) {
|
) (string, *output, *discriminator.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
server, out, alertQueue := newServeWAF(t, geojsURL, now, env)
|
server, out, alertQueue := newDiscriminator(t, geojsURL, now, env)
|
||||||
serve(t, server)
|
serve(t, server)
|
||||||
|
|
||||||
return startRProxy(t, server.Addr, appURL, env), out, server, alertQueue
|
return startRProxy(t, server.Addr, appURL, env), out, server, alertQueue
|
||||||
@@ -324,7 +324,7 @@ func startProxyWithAlerts(
|
|||||||
|
|
||||||
// serve serves server on a port of its own until the test ends, and sets its
|
// serve serves server on a port of its own until the test ends, and sets its
|
||||||
// Addr to where it listens.
|
// Addr to where it listens.
|
||||||
func serve(t *testing.T, server *servewaf.Server) {
|
func serve(t *testing.T, server *discriminator.Server) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||||
@@ -343,17 +343,17 @@ func serve(t *testing.T, server *servewaf.Server) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// startRProxy starts rproxy in front of the app at appURL, asking the servewaf
|
// startRProxy starts rproxy in front of the app at appURL, asking the discriminator
|
||||||
// at serveWAFAddr, with the settings in env on top of the defaults, and returns
|
// 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
|
// 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.
|
// env sets it, so that on a busy host SWRP_DEFAULT_DISPOSITION does not decide.
|
||||||
func startRProxy(
|
func startRProxy(
|
||||||
t *testing.T, serveWAFAddr, appURL string, env map[string]string,
|
t *testing.T, discriminatorAddr, appURL string, env map[string]string,
|
||||||
) string {
|
) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
"SWRP_WAF_BACKEND": "http://" + serveWAFAddr,
|
"SWRP_WAF_BACKEND": "http://" + discriminatorAddr,
|
||||||
"SWRP_UPSTREAM_URL": appURL,
|
"SWRP_UPSTREAM_URL": appURL,
|
||||||
"SWRP_TIMEOUT_MS": "60000",
|
"SWRP_TIMEOUT_MS": "60000",
|
||||||
}
|
}
|
||||||
@@ -390,28 +390,29 @@ func startRProxy(
|
|||||||
return listener.Addr().String()
|
return listener.Addr().String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// newServeWAF makes servewaf's server, without starting it, and returns it, what
|
// newDiscriminator makes discriminator's server, without starting it, and
|
||||||
// it writes, and the queue of the alerts it raises, as the settings in env make
|
// returns it, what it writes, and the queue of the alerts it raises, as the
|
||||||
// it. No alert is sent from the queue: they wait in it, for the test to look at.
|
// settings in env make it. No alert is sent from the queue: they wait in it,
|
||||||
|
// for the test to look at.
|
||||||
// With no geojsURL, there is no stand-in for GeoJS to look clients up at, and
|
// With no geojsURL, there is no stand-in for GeoJS to look clients up at, and
|
||||||
// SWWAF_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup
|
// SWW_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup
|
||||||
// database SWWAF_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
|
// database SWW_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
|
||||||
// at abuseIPDBURL while env sets SWWAF_ABUSEIPDB_KEY. SWWAF_WAF_MODE is off unless
|
// 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
|
// env sets it, so that only the tests of the Core Rule Set have their requests
|
||||||
// inspected by it.
|
// inspected by it.
|
||||||
func newServeWAF(
|
func newDiscriminator(
|
||||||
t *testing.T, geojsURL string, now func() time.Time, env map[string]string,
|
t *testing.T, geojsURL string, now func() time.Time, env map[string]string,
|
||||||
) (*servewaf.Server, *output, *alerts.Queue) {
|
) (*discriminator.Server, *output, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return newServeWAFWith(t, servewaf.New, geojsURL, now, env)
|
return newDiscriminatorWith(t, discriminator.New, geojsURL, now, env)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newServeWAFWith is newServeWAF for the server build makes.
|
// newDiscriminatorWith is newDiscriminator for the server build makes.
|
||||||
func newServeWAFWith(
|
func newDiscriminatorWith(
|
||||||
t *testing.T, build func(servewaf.Params) *servewaf.Server,
|
t *testing.T, build func(discriminator.Params) *discriminator.Server,
|
||||||
geojsURL string, now func() time.Time, env map[string]string,
|
geojsURL string, now func() time.Time, env map[string]string,
|
||||||
) (*servewaf.Server, *output, *alerts.Queue) {
|
) (*discriminator.Server, *output, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
@@ -463,13 +464,19 @@ func newServeWAFWith(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
server := build(servewaf.Params{
|
coreRuleSet, err := discriminator.NewCoreRuleSet(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Core Rule Set: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := build(discriminator.Params{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
RequestLog: out,
|
RequestLog: out,
|
||||||
ProcessLog: processLog,
|
ProcessLog: processLog,
|
||||||
GeoJSURL: geojsURL,
|
GeoJSURL: geojsURL,
|
||||||
AbuseIPDBURL: abuseIPDBURL,
|
AbuseIPDBURL: abuseIPDBURL,
|
||||||
LookupFile: lookupFile,
|
LookupFile: lookupFile,
|
||||||
|
CoreRuleSet: coreRuleSet,
|
||||||
Now: now,
|
Now: now,
|
||||||
Rules: ruleFiles,
|
Rules: ruleFiles,
|
||||||
Alerts: alertQueue,
|
Alerts: alertQueue,
|
||||||
@@ -507,7 +514,7 @@ func readAnswer(res *http.Response) answer {
|
|||||||
return answer{status: res.StatusCode, header: res.Header, body: body, err: err}
|
return answer{status: res.StatusCode, header: res.Header, body: body, err: err}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newRequest makes a request for path to addr, where rproxy or servewaf 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 {
|
func newRequest(t *testing.T, method, addr, path string, body io.Reader) *http.Request {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// minute is the window of SWWAF_RATE_LIMIT_PER_MINUTE, as a log line's
|
// minute is the window of SWW_RATE_LIMIT_PER_MINUTE, as a log line's
|
||||||
// limit_hit names it.
|
// limit_hit names it.
|
||||||
const minute = "minute"
|
const minute = "minute"
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ func TestRateLimitRefusesBeforeTheApp(t *testing.T) {
|
|||||||
func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
|
func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// With SWWAF_IPV6_GROUP_PREFIX at 48, the first two addresses, in two
|
// With SWW_IPV6_GROUP_PREFIX at 48, the first two addresses, in two
|
||||||
// /64s of one /48, are one client, and the second's request breaks the
|
// /64s of one /48, are one client, and the second's request breaks the
|
||||||
// limit; the third, in the next /48, is another client.
|
// limit; the third, in the next /48, is another client.
|
||||||
const (
|
const (
|
||||||
@@ -118,7 +118,7 @@ func TestIPv6GroupPrefixSetsTheClientTheLimitsCount(t *testing.T) {
|
|||||||
func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
@@ -151,7 +151,7 @@ func TestRateLimitExemptPathsAreNeitherCountedNorRefused(t *testing.T) {
|
|||||||
// /assets/, and breaks the limit.
|
// /assets/, and breaks the limit.
|
||||||
s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited)
|
s.request(client, "/assets", http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
// A ban, SWWAF_DENY_NETS and the country lists still refuse a path
|
// A ban, SWW_DENY_NETS and the country lists still refuse a path
|
||||||
// under a prefix.
|
// under a prefix.
|
||||||
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
|
s.request(client, "/assets/app.js", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
|
s.request(denied, "/assets/app.js", http.StatusForbidden, requestlog.ActionDenied)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -10,12 +10,12 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
)
|
)
|
||||||
|
|
||||||
// deny is the SWWAF_BLOCKLIST_ACTION and the SWWAF_REPUTATION_ACTION that
|
// deny is the SWW_BLOCKLIST_ACTION and the SWW_REPUTATION_ACTION that
|
||||||
// refuses the requests of a client a source lists.
|
// refuses the requests of a client a source lists.
|
||||||
const deny = "deny"
|
const deny = "deny"
|
||||||
|
|
||||||
// blocklistDenied notes the blocklists that list the client, as
|
// blocklistDenied notes the blocklists that list the client, as
|
||||||
// noteListed does, and reports whether SWWAF_BLOCKLIST_ACTION, being deny,
|
// noteListed does, and reports whether SWW_BLOCKLIST_ACTION, being deny,
|
||||||
// refuses the request. Being limit, it lowers the client's limits instead
|
// refuses the request. Being limit, it lowers the client's limits instead
|
||||||
// (see limitPercentages), and being log, it does nothing more.
|
// (see limitPercentages), and being log, it does nothing more.
|
||||||
func (rq *request) blocklistDenied() bool {
|
func (rq *request) blocklistDenied() bool {
|
||||||
@@ -43,7 +43,7 @@ func (rq *request) crowdSecBanned(now time.Time) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// dnsblDenied notes the DNSBL zones whose verdict lists the client, as
|
// dnsblDenied notes the DNSBL zones whose verdict lists the client, as
|
||||||
// noteListed does, and reports whether SWWAF_REPUTATION_ACTION, being
|
// noteListed does, and reports whether SWW_REPUTATION_ACTION, being
|
||||||
// deny, refuses the request. Being limit, it lowers the client's limits
|
// deny, refuses the request. Being limit, it lowers the client's limits
|
||||||
// instead (see limitPercentages), and being log, it does nothing more. A
|
// instead (see limitPercentages), and being log, it does nothing more. A
|
||||||
// zone without a verdict on the client is asked about it in the
|
// zone without a verdict on the client is asked about it in the
|
||||||
@@ -59,8 +59,8 @@ func (rq *request) dnsblDenied(ctx context.Context) bool {
|
|||||||
|
|
||||||
// abuseIPDBDenied notes AbuseIPDB, as noteHit does, with the score, when
|
// abuseIPDBDenied notes AbuseIPDB, as noteHit does, with the score, when
|
||||||
// its score of the client is a hit, and reports whether
|
// its score of the client is a hit, and reports whether
|
||||||
// SWWAF_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied
|
// SWW_REPUTATION_ACTION, being deny, refuses the request, as dnsblDenied
|
||||||
// does for a zone. While SWWAF_ABUSEIPDB_KEY is unset it does nothing. A
|
// does for a zone. While SWW_ABUSEIPDB_KEY is unset it does nothing. A
|
||||||
// client without a score is checked in the background, by the request's
|
// client without a score is checked in the background, by the request's
|
||||||
// address, if its history counts an offence, and the request does not
|
// address, if its history counts an offence, and the request does not
|
||||||
// wait for the answer. The score is then used for each address of the
|
// wait for the answer. The score is then used for each address of the
|
||||||
@@ -81,7 +81,7 @@ func (rq *request) abuseIPDBDenied(ctx context.Context) bool {
|
|||||||
|
|
||||||
rq.abuseIPDBHit = true
|
rq.abuseIPDBHit = true
|
||||||
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
||||||
"scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE")
|
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
|
||||||
|
|
||||||
return rq.h.config.ReputationAction == deny
|
return rq.h.config.ReputationAction == deny
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -15,20 +15,20 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The reputation settings.
|
// The reputation settings.
|
||||||
const (
|
const (
|
||||||
blocklistURLs = "SWWAF_BLOCKLIST_URLS"
|
blocklistURLs = "SWW_BLOCKLIST_URLS"
|
||||||
blocklistAction = "SWWAF_BLOCKLIST_ACTION"
|
blocklistAction = "SWW_BLOCKLIST_ACTION"
|
||||||
asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL"
|
asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The actions of SWWAF_BLOCKLIST_ACTION and SWWAF_REPUTATION_ACTION:
|
// The actions of SWW_BLOCKLIST_ACTION and SWW_REPUTATION_ACTION:
|
||||||
// limitHalf gives a listed client half of every limit, and limitQuarter a
|
// limitHalf gives a listed client half of every limit, and limitQuarter a
|
||||||
// quarter.
|
// quarter.
|
||||||
const (
|
const (
|
||||||
@@ -127,7 +127,7 @@ func TestBlocklistsComeAfterTheCountryListsAndSkipAllowNets(t *testing.T) {
|
|||||||
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
|
loadLists(t, server, map[string][]string{dropURL: {fromDE, fromKP}})
|
||||||
|
|
||||||
// fromKP's country refuses it before the list is looked at, and fromDE,
|
// fromKP's country refuses it before the list is looked at, and fromDE,
|
||||||
// in SWWAF_ALLOW_NETS, is not checked at all: neither is noted, nor
|
// in SWW_ALLOW_NETS, is not checked at all: neither is noted, nor
|
||||||
// alerted.
|
// alerted.
|
||||||
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied))
|
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied))
|
||||||
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
|
wantReputation(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward))
|
||||||
@@ -224,7 +224,7 @@ func TestASNLimitPercentFileCountsAsTheSettingDoesTheLowerWinning(t *testing.T)
|
|||||||
},
|
},
|
||||||
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
|
{"none, for an AS number the file does not list", nil, asnKP + ":25", none, none},
|
||||||
{
|
{
|
||||||
"SWWAF_ASN_BYTES_PERCENT's in place of the file's for the byte limits",
|
"SWW_ASN_BYTES_PERCENT's in place of the file's for the byte limits",
|
||||||
map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
|
map[string]string{asnBytesPercent: asnDE + ":100"}, asnDEQuarter, fromURL, none,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -299,22 +299,22 @@ func TestEachBlocklistThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
|||||||
labels := `{instance="` + alertInstance + `",source="` + listURL + `"}`
|
labels := `{instance="` + alertInstance + `",source="` + listURL + `"}`
|
||||||
|
|
||||||
if want.hits == 0 {
|
if want.hits == 0 {
|
||||||
wantNoSeries(t, metrics, "smallwebwaf_reputation_hits_total"+labels)
|
wantNoSeries(t, metrics, "sww_reputation_hits_total"+labels)
|
||||||
} else {
|
} else {
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, want.hits)
|
wantMetric(t, metrics, "sww_reputation_hits_total"+labels, want.hits)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_last_fetch_timestamp_seconds"+labels,
|
wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels,
|
||||||
want.fetched)
|
want.fetched)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The DNSBL settings.
|
// The DNSBL settings.
|
||||||
const (
|
const (
|
||||||
dnsblZones = "SWWAF_DNSBL_ZONES"
|
dnsblZones = "SWW_DNSBL_ZONES"
|
||||||
dnsblResolver = "SWWAF_DNSBL_RESOLVER"
|
dnsblResolver = "SWW_DNSBL_RESOLVER"
|
||||||
reputationAction = "SWWAF_REPUTATION_ACTION"
|
reputationAction = "SWW_REPUTATION_ACTION"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The DNSBL zones these tests name, which are never asked about the
|
// The DNSBL zones these tests name, which are never asked about the
|
||||||
@@ -370,7 +370,7 @@ func TestEachReputationActionForAClientADNSBLZoneLists(t *testing.T) {
|
|||||||
for _, from := range []string{fromDE, fromKP} {
|
for _, from := range []string{fromDE, fromKP} {
|
||||||
for i := range 3 {
|
for i := range 3 {
|
||||||
line := s.get(from, tc.statuses[i], tc.actions[i])
|
line := s.get(from, tc.statuses[i], tc.actions[i])
|
||||||
// In the order SWWAF_DNSBL_ZONES names them.
|
// In the order SWW_DNSBL_ZONES names them.
|
||||||
wantReputation(t, line, listedBy[from]...)
|
wantReputation(t, line, listedBy[from]...)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent,
|
wantPercent(t, "limit_percent", line.LimitPercent,
|
||||||
line.LimitPercentSetting, tc.percent)
|
line.LimitPercentSetting, tc.percent)
|
||||||
@@ -416,7 +416,7 @@ func TestDNSBLZonesComeAfterTheBlocklistsAndSkipAllowNets(t *testing.T) {
|
|||||||
loadVerdicts(server, map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}})
|
loadVerdicts(server, map[string][]string{fromKP: {dnsblZone}, fromDE: {dnsblZone}})
|
||||||
|
|
||||||
// The blocklist refuses fromKP before its verdict is looked at, and
|
// The blocklist refuses fromKP before its verdict is looked at, and
|
||||||
// fromDE, in SWWAF_ALLOW_NETS, is not checked at all: neither is noted
|
// fromDE, in SWW_ALLOW_NETS, is not checked at all: neither is noted
|
||||||
// for the zone, nor alerted, nor asked about.
|
// for the zone, nor alerted, nor asked about.
|
||||||
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
|
wantReputation(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionDenied),
|
||||||
dropURL)
|
dropURL)
|
||||||
@@ -537,9 +537,9 @@ func TestEachZoneThatListsAClientRaisesAnAlertOncePerCooldownAndIsCounted(
|
|||||||
for _, zone := range []string{dnsblZone, otherZone} {
|
for _, zone := range []string{dnsblZone, otherZone} {
|
||||||
labels := `{instance="` + alertInstance + `",source="` + zone + `"}`
|
labels := `{instance="` + alertInstance + `",source="` + zone + `"}`
|
||||||
|
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2)
|
wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 2)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_queries_total"+labels, 0)
|
wantMetric(t, metrics, "sww_reputation_queries_total"+labels, 0)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,7 +567,7 @@ func TestZoneKeyIsMaskedInTheLogTheAlertAndTheMetrics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics, `smallwebwaf_reputation_hits_total{instance="`+
|
wantMetric(t, metrics, `sww_reputation_hits_total{instance="`+
|
||||||
alertInstance+`",source="`+masked+`"}`, 1)
|
alertInstance+`",source="`+masked+`"}`, 1)
|
||||||
|
|
||||||
for name, shown := range map[string]string{
|
for name, shown := range map[string]string{
|
||||||
@@ -609,7 +609,7 @@ func TestRequestFromAClientWithoutAVerdictHasTheZoneAskedAboutIt(t *testing.T) {
|
|||||||
|
|
||||||
// The AbuseIPDB settings, and accountKey, the key the tests set.
|
// The AbuseIPDB settings, and accountKey, the key the tests set.
|
||||||
const (
|
const (
|
||||||
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY"
|
abuseIPDBKey = "SWW_ABUSEIPDB_KEY"
|
||||||
accountKey = "abuseipdb-key-0123456789abcdef"
|
accountKey = "abuseipdb-key-0123456789abcdef"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -617,7 +617,7 @@ const (
|
|||||||
// AbuseIPDB.
|
// AbuseIPDB.
|
||||||
const abuseipdb = reputation.AbuseIPDBSource
|
const abuseipdb = reputation.AbuseIPDBSource
|
||||||
|
|
||||||
// abuseIPDBURL is where newServeWAF 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,
|
// abuseIPDBStandIn, which TestMain registers with Go's default transport,
|
||||||
// through which AbuseIPDB is asked.
|
// through which AbuseIPDB is asked.
|
||||||
const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check"
|
const abuseIPDBURL = "abuseipdb://stand-in/api/v2/check"
|
||||||
@@ -800,7 +800,7 @@ func TestEachReputationActionForAClientAbuseIPDBScoresAtOrOverTheMinimum(t *test
|
|||||||
rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey,
|
rateLimitPerMinute: fourAMinute, abuseIPDBKey: accountKey,
|
||||||
reputationAction: tc.action,
|
reputationAction: tc.action,
|
||||||
})
|
})
|
||||||
// At SWWAF_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
|
// At SWW_ABUSEIPDB_MIN_SCORE, 75 by default, and just under it.
|
||||||
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
|
loadScores(server, map[string]int64{fromDE: 75, fromKP: 74})
|
||||||
|
|
||||||
for i := range 3 {
|
for i := range 3 {
|
||||||
@@ -851,7 +851,7 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
|
|||||||
// The alert is made as a DNSBL zone's is, with the score besides.
|
// The alert is made as a DNSBL zone's is, with the score besides.
|
||||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
||||||
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit ||
|
if len(waiting) != 1 || waiting[0].Event != alerts.EventReputationHit ||
|
||||||
waiting[0].Reason != "scored by AbuseIPDB at or over SWWAF_ABUSEIPDB_MIN_SCORE" ||
|
waiting[0].Reason != "scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE" ||
|
||||||
waiting[0].Detail["source"] != abuseipdb || waiting[0].Detail["score"] != int64(90) ||
|
waiting[0].Detail["source"] != abuseipdb || waiting[0].Detail["score"] != int64(90) ||
|
||||||
queue.Suppressed() != 1 {
|
queue.Suppressed() != 1 {
|
||||||
t.Errorf("alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+
|
t.Errorf("alerts waiting %+v, %d held back, want AbuseIPDB's reputation_hit "+
|
||||||
@@ -863,10 +863,10 @@ func TestAbuseIPDBHitRaisesAnAlertWithTheScoreOncePerCooldownAndIsCounted(
|
|||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
labels := `{instance="` + alertInstance + `",source="` + abuseipdb + `"}`
|
labels := `{instance="` + alertInstance + `",source="` + abuseipdb + `"}`
|
||||||
|
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 2)
|
wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 2)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_queries_total"+labels, 0)
|
wantMetric(t, metrics, "sww_reputation_queries_total"+labels, 0)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
|
||||||
wantMetric(t, metrics, "smallwebwaf_reputation_daily_budget_remaining"+labels, 900)
|
wantMetric(t, metrics, "sww_reputation_daily_budget_remaining"+labels, 900)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
|
func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
|
||||||
@@ -887,7 +887,7 @@ func TestWithoutAnAbuseIPDBKeyNoClientIsCheckedNorAScoreUsed(t *testing.T) {
|
|||||||
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
|
wantReputation(t, s.get(fromDE, http.StatusOK, forward))
|
||||||
wantAbuseIPDBChecks(t, server, 0)
|
wantAbuseIPDBChecks(t, server, 0)
|
||||||
|
|
||||||
wantNoSeries(t, s.scrape(), `smallwebwaf_reputation_daily_budget_remaining{`+
|
wantNoSeries(t, s.scrape(), `sww_reputation_daily_budget_remaining{`+
|
||||||
`instance="`+alertInstance+`",source="`+abuseipdb+`"}`)
|
`instance="`+alertInstance+`",source="`+abuseipdb+`"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -922,7 +922,7 @@ func TestBanNotesNameEachReputationSourceThatListedTheClient(t *testing.T) {
|
|||||||
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
abuseIPDBKey: accountKey, reputationAction: actionLog,
|
||||||
})
|
})
|
||||||
// Every source lists client, and none otherClient, whose score is
|
// Every source lists client, and none otherClient, whose score is
|
||||||
// under SWWAF_ABUSEIPDB_MIN_SCORE, 75 by default.
|
// under SWW_ABUSEIPDB_MIN_SCORE, 75 by default.
|
||||||
loadLists(t, server, map[string][]string{dropURL: {client}})
|
loadLists(t, server, map[string][]string{dropURL: {client}})
|
||||||
loadVerdicts(server, map[string][]string{client: {dnsblZone}, otherClient: nil})
|
loadVerdicts(server, map[string][]string{client: {dnsblZone}, otherClient: nil})
|
||||||
loadScores(server, map[string]int64{client: score, otherClient: 74})
|
loadScores(server, map[string]int64{client: score, otherClient: 74})
|
||||||
@@ -971,11 +971,11 @@ func verdictsFetched() time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadVerdicts puts into server's DNSBL, for each client listedBy names,
|
// loadVerdicts puts into server's DNSBL, for each client listedBy names,
|
||||||
// a verdict of each zone SWWAF_DNSBL_ZONES names, fetched at
|
// a verdict of each zone SWW_DNSBL_ZONES names, fetched at
|
||||||
// verdictsFetched, as reputation.json would at start: one that lists the
|
// 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
|
// client from each zone listedBy gives for it, and one that does not from
|
||||||
// each other zone.
|
// each other zone.
|
||||||
func loadVerdicts(server *servewaf.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()))
|
verdicts := make([]reputation.Verdict, 0, len(listedBy)*len(server.DNSBL.Zones()))
|
||||||
|
|
||||||
for client, zones := range listedBy {
|
for client, zones := range listedBy {
|
||||||
@@ -993,7 +993,7 @@ func loadVerdicts(server *servewaf.Server, listedBy map[string][]string) {
|
|||||||
// loadScores puts into server's AbuseIPDB the score scores gives each
|
// loadScores puts into server's AbuseIPDB the score scores gives each
|
||||||
// client, an IPv4 address, fetched at verdictsFetched, as reputation.json
|
// client, an IPv4 address, fetched at verdictsFetched, as reputation.json
|
||||||
// would at start.
|
// would at start.
|
||||||
func loadScores(server *servewaf.Server, scores map[string]int64) {
|
func loadScores(server *discriminator.Server, scores map[string]int64) {
|
||||||
kept := make([]reputation.Score, 0, len(scores))
|
kept := make([]reputation.Score, 0, len(scores))
|
||||||
for client, score := range scores {
|
for client, score := range scores {
|
||||||
kept = append(kept, reputation.Score{
|
kept = append(kept, reputation.Score{
|
||||||
@@ -1007,7 +1007,7 @@ func loadScores(server *servewaf.Server, scores map[string]int64) {
|
|||||||
|
|
||||||
// wantAbuseIPDBChecks checks how many clients server has checked with
|
// wantAbuseIPDBChecks checks how many clients server has checked with
|
||||||
// AbuseIPDB.
|
// AbuseIPDB.
|
||||||
func wantAbuseIPDBChecks(t *testing.T, server *servewaf.Server, want int) {
|
func wantAbuseIPDBChecks(t *testing.T, server *discriminator.Server, want int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
if got := server.AbuseIPDB.Checked(); got != want {
|
if got := server.AbuseIPDB.Checked(); got != want {
|
||||||
@@ -1017,7 +1017,7 @@ func wantAbuseIPDBChecks(t *testing.T, server *servewaf.Server, want int) {
|
|||||||
|
|
||||||
// loadLists puts copies of lists into server's lists, by URL, each with
|
// loadLists puts copies of lists into server's lists, by URL, each with
|
||||||
// its lines, fetched at listsFetched, as reputation.json would at start.
|
// its lines, fetched at listsFetched, as reputation.json would at start.
|
||||||
func loadLists(t *testing.T, server *servewaf.Server, copies map[string][]string) {
|
func loadLists(t *testing.T, server *discriminator.Server, copies map[string][]string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
lists := make([]reputation.List, 0, len(copies))
|
lists := make([]reputation.List, 0, len(copies))
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// refusal is servewaf 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.
|
// to close its connection without an answer, and the action the log line names.
|
||||||
type refusal struct {
|
type refusal struct {
|
||||||
status int
|
status int
|
||||||
@@ -25,7 +25,7 @@ type refusal struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// decision is what the checks note of a request: its log line, and what
|
// decision is what the checks note of a request: its log line, and what
|
||||||
// counting the request needs once it has ended. servewaf 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
|
// 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.
|
// never does, and neither the request's objects nor their bodies.
|
||||||
type decision struct {
|
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 is the path with the query, as a ban's notes keep it.
|
||||||
requestURI string
|
requestURI string
|
||||||
// site is, for a question, the host the visitor asked for, which the
|
// site is, for a question, the host the visitor asked for, which the
|
||||||
// alerts name, since one servewaf serves many sites.
|
// alerts name, since one discriminator serves many sites.
|
||||||
site string
|
site string
|
||||||
// allowed is true for a question answered with DispositionAllow.
|
// allowed is true for a question answered with DispositionAllow.
|
||||||
allowed bool
|
allowed bool
|
||||||
@@ -44,7 +44,7 @@ type decision struct {
|
|||||||
lookedUp bool
|
lookedUp bool
|
||||||
lookupAnswer lookup.Answer
|
lookupAnswer lookup.Answer
|
||||||
// skippedLookupWait is true for a request that did not wait for GeoJS and
|
// skippedLookupWait is true for a request that did not wait for GeoJS and
|
||||||
// found no answer kept. SWWAF_UNKNOWN_LIMIT_PERCENT does not apply to it,
|
// found no answer kept. SWW_UNKNOWN_LIMIT_PERCENT does not apply to it,
|
||||||
// so that not waiting never lowers its client's limits.
|
// so that not waiting never lowers its client's limits.
|
||||||
skippedLookupWait bool
|
skippedLookupWait bool
|
||||||
// counted is true for a request the rate limits counted, whose bytes
|
// counted is true for a request the rate limits counted, whose bytes
|
||||||
@@ -63,7 +63,7 @@ type decision struct {
|
|||||||
reputation []bans.ReputationHit
|
reputation []bans.ReputationHit
|
||||||
}
|
}
|
||||||
|
|
||||||
// request is one request to servewaf, 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.
|
// read to its log line.
|
||||||
type request struct {
|
type request struct {
|
||||||
decision
|
decision
|
||||||
@@ -161,7 +161,7 @@ func requestHeaders(r *http.Request, names []string) map[string]string {
|
|||||||
|
|
||||||
// check is the one place where a request can be refused. It returns nil to let
|
// check is the one place where a request can be refused. It returns nil to let
|
||||||
// the request through. The checks of checkClient decide: a request they refuse
|
// the request through. The checks of checkClient decide: a request they refuse
|
||||||
// is answered with SWWAF_BAN_RESPONSE, 403 for a block rule or the Core Rule Set,
|
// is answered with SWW_BAN_RESPONSE, 403 for a block rule or the Core Rule Set,
|
||||||
// or 431 for the header limits. In observe mode nothing is refused, and the log
|
// or 431 for the header limits. In observe mode nothing is refused, and the log
|
||||||
// line names what enforce mode would have done. ctx is the request's own context.
|
// line names what enforce mode would have done. ctx is the request's own context.
|
||||||
func (rq *request) check(ctx context.Context) *refusal {
|
func (rq *request) check(ctx context.Context) *refusal {
|
||||||
@@ -185,17 +185,17 @@ func (rq *request) check(ctx context.Context) *refusal {
|
|||||||
|
|
||||||
// checkClient runs the checks on the request's client, and returns the
|
// checkClient runs the checks on the request's client, and returns the
|
||||||
// action of the first that refuses the request, or "" when none does. A
|
// action of the first that refuses the request, or "" when none does. A
|
||||||
// client in SWWAF_ALLOW_NETS skips them, and is not looked up. For any
|
// client in SWW_ALLOW_NETS skips them, and is not looked up. For any
|
||||||
// other client, SWWAF_DENY_NETS comes first, then a ban on its netblock,
|
// other client, SWW_DENY_NETS comes first, then a ban on its netblock,
|
||||||
// so that a client either refuses is not looked up, then the lookup of
|
// so that a client either refuses is not looked up, then the lookup of
|
||||||
// its AS number and country, then the country lists, then the blocklists,
|
// its AS number and country, then the country lists, then the blocklists,
|
||||||
// then the CrowdSec decision list, which bans the client it lists, then
|
// then the CrowdSec decision list, which bans the client it lists, then
|
||||||
// the DNSBL zones' verdicts, and then AbuseIPDB's score; a request any of
|
// the DNSBL zones' verdicts, and then AbuseIPDB's score; a request any of
|
||||||
// them refuses is not counted for the rate limits. Then come the
|
// them refuses is not counted for the rate limits. Then come the
|
||||||
// rate limits, unless the client is in SWWAF_RATE_LIMIT_EXEMPT_NETS or the
|
// rate limits, unless the client is in SWW_RATE_LIMIT_EXEMPT_NETS or the
|
||||||
// request's path is exempt under SWWAF_RATE_LIMIT_EXEMPT_PATHS, so that
|
// request's path is exempt under SWW_RATE_LIMIT_EXEMPT_PATHS, so that
|
||||||
// every other request is counted, each of them by the client's limit
|
// every other request is counted, each of them by the client's limit
|
||||||
// percentages, then SWWAF_TRAP_PATHS, then the rule files, then the header
|
// percentages, then SWW_TRAP_PATHS, then the rule files, then the header
|
||||||
// limits, and last the Core Rule Set. A request exempt from the rate limits
|
// limits, and last the Core Rule Set. A request exempt from the rate limits
|
||||||
// is exempt from the byte limits too. ctx is the request's own context.
|
// is exempt from the byte limits too. ctx is the request's own context.
|
||||||
func (rq *request) checkClient(ctx context.Context) string {
|
func (rq *request) checkClient(ctx context.Context) string {
|
||||||
@@ -266,8 +266,8 @@ func (rq *request) checkClient(ctx context.Context) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// countedByRateLimits reports whether the rate limits count the request: its
|
// countedByRateLimits reports whether the rate limits count the request: its
|
||||||
// client is not in SWWAF_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
|
// client is not in SWW_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
|
||||||
// under SWWAF_RATE_LIMIT_EXEMPT_PATHS.
|
// under SWW_RATE_LIMIT_EXEMPT_PATHS.
|
||||||
func (rq *request) countedByRateLimits() bool {
|
func (rq *request) countedByRateLimits() bool {
|
||||||
cfg := rq.h.config
|
cfg := rq.h.config
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@ func (rq *request) countedByRateLimits() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pathExempt reports whether r is exempt under prefixes,
|
// pathExempt reports whether r is exempt under prefixes,
|
||||||
// SWWAF_RATE_LIMIT_EXEMPT_PATHS or SWWAF_WAF_EXEMPT_PATHS: whether its
|
// SWW_RATE_LIMIT_EXEMPT_PATHS or SWW_WAF_EXEMPT_PATHS: whether its
|
||||||
// path as sent, the path the app receives, not percent-decoded, starts
|
// path as sent, the path the app receives, not percent-decoded, starts
|
||||||
// with one of prefixes, so that /%61ssets/x is not under /assets/ for an
|
// with one of prefixes, so that /%61ssets/x is not under /assets/ for an
|
||||||
// app whose router matches the path as received. A request whose decoded
|
// app whose router matches the path as received. A request whose decoded
|
||||||
@@ -378,9 +378,9 @@ func (rq *request) addToHistory() {
|
|||||||
|
|
||||||
// countAnomalies counts the request, which has ended, and its bytes, as
|
// countAnomalies counts the request, which has ended, and its bytes, as
|
||||||
// countedBytes gives them, for the anomaly thresholds, whatever was done
|
// countedBytes gives them, for the anomaly thresholds, whatever was done
|
||||||
// with it: a request refused, one from a client in SWWAF_ALLOW_NETS or
|
// with it: a request refused, one from a client in SWW_ALLOW_NETS or
|
||||||
// SWWAF_RATE_LIMIT_EXEMPT_NETS, and one for a path in
|
// SWW_RATE_LIMIT_EXEMPT_NETS, and one for a path in
|
||||||
// SWWAF_RATE_LIMIT_EXEMPT_PATHS are counted too. It is counted for its
|
// SWW_RATE_LIMIT_EXEMPT_PATHS are counted too. It is counted for its
|
||||||
// client's AS number when answerAtTheEnd gives one. With every anomaly
|
// client's AS number when answerAtTheEnd gives one. With every anomaly
|
||||||
// threshold off, the default, it does nothing.
|
// threshold off, the default, it does nothing.
|
||||||
func (rq *request) countAnomalies() {
|
func (rq *request) countAnomalies() {
|
||||||
@@ -425,7 +425,7 @@ func (rq *request) answerAtTheEnd() (lookup.Answer, bool) {
|
|||||||
return rq.h.geojs.Kept(rq.h.clientGroup(rq.client))
|
return rq.h.geojs.Kept(rq.h.clientGroup(rq.client))
|
||||||
}
|
}
|
||||||
|
|
||||||
// responseWriter is servewaf'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.
|
// size for the log line, and the first error writing the answer.
|
||||||
type responseWriter struct {
|
type responseWriter struct {
|
||||||
http.ResponseWriter
|
http.ResponseWriter
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
@@ -10,15 +10,15 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// requestIDHeader carries the request's id.
|
// requestIDHeader carries the request's id.
|
||||||
requestIDHeader = "X-Request-ID"
|
requestIDHeader = "X-Request-ID"
|
||||||
// instance is the SWWAF_INSTANCE_NAME a test sets.
|
// instance is the SWW_INSTANCE_NAME a test sets.
|
||||||
instance = "fsn1app1/blog"
|
instance = "fsn1app1/blog"
|
||||||
// ipv6Client is a client on IPv6, and ipv6Group the netblock the rate
|
// ipv6Client is a client on IPv6, and ipv6Group the netblock the rate
|
||||||
// limits count it as.
|
// limits count it as.
|
||||||
@@ -104,7 +104,7 @@ func wantFullLine(t *testing.T, line logLine) {
|
|||||||
headers := map[string]string{"accept": "text/html", "x-custom": "one, two"}
|
headers := map[string]string{"accept": "text/html", "x-custom": "one, two"}
|
||||||
|
|
||||||
want := withTimings(line, requestlog.Line{
|
want := withTimings(line, requestlog.Line{
|
||||||
Type: requestType, ServeWAFLine: requestlog.ServeWAFLineReport,
|
Type: requestType, DiscriminatorLine: requestlog.DiscriminatorLineReport,
|
||||||
Time: line.Time, Instance: instance,
|
Time: line.Time, Instance: instance,
|
||||||
ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure,
|
ClientIP: ipv6Client, Method: http.MethodPost, Scheme: secure,
|
||||||
Host: appHost, Path: "/full", Protocol: protocol,
|
Host: appHost, Path: "/full", Protocol: protocol,
|
||||||
@@ -130,7 +130,7 @@ func wantBareLine(t *testing.T, line logLine) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
want := withTimings(line, requestlog.Line{
|
want := withTimings(line, requestlog.Line{
|
||||||
Type: requestType, ServeWAFLine: requestlog.ServeWAFLineReport,
|
Type: requestType, DiscriminatorLine: requestlog.DiscriminatorLineReport,
|
||||||
Time: line.Time, Instance: instance,
|
Time: line.Time, Instance: instance,
|
||||||
ClientIP: localhost, Method: http.MethodPost, Scheme: plain,
|
ClientIP: localhost, Method: http.MethodPost, Scheme: plain,
|
||||||
Host: appHost, Path: "/bare", Protocol: protocol,
|
Host: appHost, Path: "/bare", Protocol: protocol,
|
||||||
@@ -195,7 +195,7 @@ func TestHasAuthorizationAndHasCookieEachComeFromTheirOwnHeader(t *testing.T) {
|
|||||||
func TestTimingsAreInOrder(t *testing.T) {
|
func TestTimingsAreInOrder(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
||||||
|
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
// The pause sets the times apart; a hold-up of the test only lengthens
|
// The pause sets the times apart; a hold-up of the test only lengthens
|
||||||
@@ -220,7 +220,7 @@ func TestTimingsAreInOrder(t *testing.T) {
|
|||||||
wantStatus(t, do(t, req), http.StatusForbidden)
|
wantStatus(t, do(t, req), http.StatusForbidden)
|
||||||
refused := out.endedLines(t, 2)[1]
|
refused := out.endedLines(t, 2)[1]
|
||||||
|
|
||||||
wantStatus(t, get(t, server.Addr, servewaf.HealthPath), http.StatusOK)
|
wantStatus(t, get(t, server.Addr, discriminator.HealthPath), http.StatusOK)
|
||||||
health := out.endedLines(t, 3)[2]
|
health := out.endedLines(t, 3)[2]
|
||||||
|
|
||||||
// A request passed to the app has every timing; one refused, none of
|
// A request passed to the app has every timing; one refused, none of
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -34,14 +34,14 @@ func TestEachRuleAction(t *testing.T) {
|
|||||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
wantRuleIDs(t, line, "noted")
|
wantRuleIDs(t, line, "noted")
|
||||||
|
|
||||||
// A block rule refuses with 403, whatever SWWAF_BAN_RESPONSE is, and
|
// A block rule refuses with 403, whatever SWW_BAN_RESPONSE is, and
|
||||||
// bans no one.
|
// bans no one.
|
||||||
line = s.request(client, "/blocked", http.StatusForbidden,
|
line = s.request(client, "/blocked", http.StatusForbidden,
|
||||||
requestlog.ActionRuleBlocked)
|
requestlog.ActionRuleBlocked)
|
||||||
wantRuleIDs(t, line, "noted", "blocked")
|
wantRuleIDs(t, line, "noted", "blocked")
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
// A ban rule refuses with SWWAF_BAN_RESPONSE, and bans the client for
|
// A ban rule refuses with SWW_BAN_RESPONSE, and bans the client for
|
||||||
// seven days, the default.
|
// seven days, the default.
|
||||||
line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned)
|
line = s.request(client, "/.env", http.StatusTooManyRequests, requestlog.ActionBanned)
|
||||||
wantRuleIDs(t, line, "noted", "probe")
|
wantRuleIDs(t, line, "noted", "probe")
|
||||||
@@ -101,7 +101,7 @@ func TestNextClearSignOfAttackAfterABanBansPermanently(t *testing.T) {
|
|||||||
attackBanDuration: "1h",
|
attackBanDuration: "1h",
|
||||||
})
|
})
|
||||||
|
|
||||||
// The first probe bans for SWWAF_ATTACK_BAN_DURATION.
|
// The first probe bans for SWW_ATTACK_BAN_DURATION.
|
||||||
line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
line := s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
|
if line.BanExpires != requestlog.FormatTime(clk.Now().Add(time.Hour)) {
|
||||||
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
|
t.Errorf("log line has ban_expires %q, want an hour on", line.BanExpires)
|
||||||
@@ -122,8 +122,8 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||||
exempt = "192.0.2.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
s, _, server := startWithClock(t, "", map[string]string{
|
||||||
@@ -133,7 +133,7 @@ func TestRulesComeAfterTheOtherChecks(t *testing.T) {
|
|||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
// A client in SWWAF_ALLOW_NETS is not checked.
|
// A client in SWW_ALLOW_NETS is not checked.
|
||||||
line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward)
|
line := s.request(allowed, "/.env", http.StatusOK, requestlog.ActionForward)
|
||||||
wantRuleIDs(t, line)
|
wantRuleIDs(t, line)
|
||||||
|
|
||||||
@@ -196,18 +196,18 @@ func TestMetricsCountRuleMatchesAndBansForAnAttack(t *testing.T) {
|
|||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1)
|
`sww_rule_matches_total{action="block",instance="app",rule_id="blocked"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1)
|
`sww_rule_matches_total{action="ban",instance="app",rule_id="probe"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_rules_loaded{instance="app"}`, 2)
|
wantMetric(t, metrics, `sww_rules_loaded{instance="app"}`, 2)
|
||||||
wantMetric(t, metrics, `smallwebwaf_requests_total{action="rule_blocked",`+
|
wantMetric(t, metrics, `sww_requests_total{action="rule_blocked",`+
|
||||||
`instance="app",status_class="4xx"}`, 1)
|
`instance="app",status_class="4xx"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 1)
|
`sww_offences_total{instance="app",kind="rule_blocked"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="attack"}`, 1)
|
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="attack"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="attack",instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_bans_made_total{cause="attack",instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 0)
|
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 0)
|
||||||
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1)
|
wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeRules writes content as a rule file into a new directory, and
|
// writeRules writes content as a rule file into a new directory, and
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf
|
package discriminator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"slices"
|
"slices"
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// trapPath reports whether the request asks for a path in
|
// trapPath reports whether the request asks for a path in
|
||||||
// SWWAF_TRAP_PATHS: its path as a path rule sees it, without the query,
|
// SWW_TRAP_PATHS: its path as a path rule sees it, without the query,
|
||||||
// as sent or once percent-decoded, is one of them. Such a request is a
|
// as sent or once percent-decoded, is one of them. Such a request is a
|
||||||
// clear sign of attack, as a ban rule's match is: it bans the client's
|
// clear sign of attack, as a ban rule's match is: it bans the client's
|
||||||
// netblock, or in observe mode raises the alert for the ban it would have
|
// netblock, or in observe mode raises the alert for the ban it would have
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -26,7 +26,7 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) {
|
|||||||
calls.Add(1)
|
calls.Add(1)
|
||||||
})
|
})
|
||||||
geojsURL, asked := startGeoJS(t)
|
geojsURL, asked := startGeoJS(t)
|
||||||
// fromKP is in SWWAF_ALLOW_NETS, and in SWWAF_DENY_NETS too, which
|
// fromKP is in SWW_ALLOW_NETS, and in SWW_DENY_NETS too, which
|
||||||
// comes after it.
|
// comes after it.
|
||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
@@ -36,7 +36,7 @@ func TestAllowNetsSkipEveryCheck(t *testing.T) {
|
|||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
// Neither SWWAF_DENY_NETS, the country lists nor the limit of one
|
// Neither SWW_DENY_NETS, the country lists nor the limit of one
|
||||||
// request a minute refuses the client, and its country is not looked
|
// request a minute refuses the client, and its country is not looked
|
||||||
// up.
|
// up.
|
||||||
wantAnswers(t, addr, out, []sentRequest{
|
wantAnswers(t, addr, out, []sentRequest{
|
||||||
@@ -157,9 +157,9 @@ type sentRequest struct {
|
|||||||
action string
|
action string
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantAnswers sends requests to smallwebwaf at addr one after another and
|
// wantAnswers sends requests to sww at addr one after another and
|
||||||
// checks each one's answer and log line. They must be the first requests
|
// checks each one's answer and log line. They must be the first requests
|
||||||
// smallwebwaf is sent, since the log lines are matched to them in order.
|
// sww is sent, since the log lines are matched to them in order.
|
||||||
func wantAnswers(t *testing.T, addr string, out *output, requests []sentRequest) {
|
func wantAnswers(t *testing.T, addr string, out *output, requests []sentRequest) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package servewaf_test
|
package discriminator_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,14 +14,14 @@ import (
|
|||||||
// trapPaths is the setting's name, and trapPathList what the tests set it
|
// trapPaths is the setting's name, and trapPathList what the tests set it
|
||||||
// to.
|
// to.
|
||||||
const (
|
const (
|
||||||
trapPaths = "SWWAF_TRAP_PATHS"
|
trapPaths = "SWW_TRAP_PATHS"
|
||||||
trapPathList = "/wp-login.php,/xmlrpc.php"
|
trapPathList = "/wp-login.php,/xmlrpc.php"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
const allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
||||||
|
|
||||||
// A block rule for the same path: the trap path comes first.
|
// A block rule for the same path: the trap path comes first.
|
||||||
s, clk, server := startWithClock(t, "", map[string]string{
|
s, clk, server := startWithClock(t, "", map[string]string{
|
||||||
@@ -38,7 +38,7 @@ func TestTrapPathBansAsABanRuleDoes(t *testing.T) {
|
|||||||
s.request(otherClient, path, http.StatusOK, requestlog.ActionForward)
|
s.request(otherClient, path, http.StatusOK, requestlog.ActionForward)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A client in SWWAF_ALLOW_NETS is not checked.
|
// A client in SWW_ALLOW_NETS is not checked.
|
||||||
s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
|
s.request(allowed, "/xmlrpc.php", http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
// The query is not part of the path.
|
// The query is not part of the path.
|
||||||
@@ -104,7 +104,7 @@ func TestTrapPathsNeedNoRuleFiles(t *testing.T) {
|
|||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
trapPaths: trapPathList,
|
trapPaths: trapPathList,
|
||||||
"SWWAF_RULES_ENABLED": "false",
|
"SWW_RULES_ENABLED": "false",
|
||||||
})
|
})
|
||||||
|
|
||||||
s.request(client, "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned)
|
s.request(client, "/xmlrpc.php", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
@@ -24,7 +24,7 @@ const quietTime = 2 * time.Second
|
|||||||
// FileParams are what OpenFile needs.
|
// FileParams are what OpenFile needs.
|
||||||
type FileParams struct {
|
type FileParams struct {
|
||||||
// Path is the lookup database, the IPinfo Lite file in its .mmdb form
|
// Path is the lookup database, the IPinfo Lite file in its .mmdb form
|
||||||
// (SWWAF_LOOKUP_DB_PATH).
|
// (SWW_LOOKUP_DB_PATH).
|
||||||
Path string
|
Path string
|
||||||
// Now tells the time, normally time.Now.
|
// Now tells the time, normally time.Now.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
@@ -51,7 +51,7 @@ type File struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// record is what the lookup database holds about a network, of the fields
|
// record is what the lookup database holds about a network, of the fields
|
||||||
// smallwebwaf reads.
|
// sww reads.
|
||||||
type record struct {
|
type record struct {
|
||||||
ASN string `maxminddb:"asn"`
|
ASN string `maxminddb:"asn"`
|
||||||
ASName string `maxminddb:"as_name"`
|
ASName string `maxminddb:"as_name"`
|
||||||
@@ -221,12 +221,12 @@ func (f *File) use(reader *maxminddb.Reader) {
|
|||||||
func read(path string) (*maxminddb.Reader, error) {
|
func read(path string) (*maxminddb.Reader, error) {
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
|
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH cannot be read: %w", err)
|
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH cannot be read: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
reader, err := maxminddb.OpenBytes(data)
|
reader, err := maxminddb.OpenBytes(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
|
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return reader, nil
|
return reader, nil
|
||||||
|
|||||||
@@ -99,9 +99,9 @@ func TestFileThatCannotBeReadIsAnError(t *testing.T) {
|
|||||||
writeFile(t, notDatabase, "not a lookup database\n")
|
writeFile(t, notDatabase, "not a lookup database\n")
|
||||||
|
|
||||||
for path, want := range map[string]string{
|
for path, want := range map[string]string{
|
||||||
missing: "SWWAF_LOOKUP_DB_PATH cannot be read: open " + missing +
|
missing: "SWW_LOOKUP_DB_PATH cannot be read: open " + missing +
|
||||||
": no such file or directory",
|
": no such file or directory",
|
||||||
notDatabase: "SWWAF_LOOKUP_DB_PATH " + notDatabase +
|
notDatabase: "SWW_LOOKUP_DB_PATH " + notDatabase +
|
||||||
" is not a .mmdb file: error opening database: invalid MaxMind DB file",
|
" is not a .mmdb file: error opening database: invalid MaxMind DB file",
|
||||||
} {
|
} {
|
||||||
_, err := OpenFile(FileParams{
|
_, err := OpenFile(FileParams{
|
||||||
@@ -216,7 +216,7 @@ func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(t *testing.T)
|
|||||||
Reason: "the lookup database cannot be read, and the one read before stays in use",
|
Reason: "the lookup database cannot be read, and the one read before stays in use",
|
||||||
Detail: map[string]any{
|
Detail: map[string]any{
|
||||||
"file": path,
|
"file": path,
|
||||||
"error": "SWWAF_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
|
"error": "SWW_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
|
||||||
"error opening database: invalid MaxMind DB file",
|
"error opening database: invalid MaxMind DB file",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Package lookup looks up each client's AS number and country, through
|
// Package lookup looks up each client's AS number and country, through
|
||||||
// the GeoJS web service or in the lookup database, the IPinfo Lite file
|
// the GeoJS web service or in the lookup database, the IPinfo Lite file
|
||||||
// SWWAF_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
|
// SWW_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
|
||||||
// most 100,000 clients and for 7 days each, and are written to
|
// most 100,000 clients and for 7 days each, and are written to
|
||||||
// lookups.json and read from it by the state package.
|
// lookups.json and read from it by the state package.
|
||||||
package lookup
|
package lookup
|
||||||
@@ -66,7 +66,7 @@ type Params struct {
|
|||||||
URL string
|
URL string
|
||||||
// Timeout is how long a request waits for its client's first answer,
|
// Timeout is how long a request waits for its client's first answer,
|
||||||
// and how long a request to GeoJS may take before it is abandoned
|
// and how long a request to GeoJS may take before it is abandoned
|
||||||
// (SWWAF_LOOKUP_TIMEOUT).
|
// (SWW_LOOKUP_TIMEOUT).
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Wait is true when a setting needs each request's answer before the
|
// Wait is true when a setting needs each request's answer before the
|
||||||
// request goes on. Otherwise no request waits for one.
|
// request goes on. Otherwise no request waits for one.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
const fileMode = 0o600
|
const fileMode = 0o600
|
||||||
|
|
||||||
// Network is what a lookup database holds about a netblock, of the fields
|
// Network is what a lookup database holds about a netblock, of the fields
|
||||||
// smallwebwaf reads: its AS number, such as AS64496, the AS's name, and
|
// sww reads: its AS number, such as AS64496, the AS's name, and
|
||||||
// its country, such as DE.
|
// its country, such as DE.
|
||||||
type Network struct {
|
type Network struct {
|
||||||
ASN string
|
ASN string
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ type busiest struct {
|
|||||||
// for the topN busiest countries.
|
// for the topN busiest countries.
|
||||||
func newCountries(topN int) *busiest {
|
func newCountries(topN int) *busiest {
|
||||||
countries := newBusiest(topN, "country", "the client's country")
|
countries := newBusiest(topN, "country", "the client's country")
|
||||||
countries.refused = counterVec("smallwebwaf_country_list_refusals_total",
|
countries.refused = counterVec("sww_country_list_refusals_total",
|
||||||
"Requests the country lists refused, by the client's country.",
|
"Requests the country lists refused, by the client's country.",
|
||||||
[]string{"country"})
|
[]string{"country"})
|
||||||
|
|
||||||
@@ -61,11 +61,11 @@ func newBusiest(topN int, label, description string) *busiest {
|
|||||||
|
|
||||||
return &busiest{
|
return &busiest{
|
||||||
topN: topN,
|
topN: topN,
|
||||||
requests: counterVec("smallwebwaf_"+label+"_requests_total",
|
requests: counterVec("sww_"+label+"_requests_total",
|
||||||
"Requests, by "+description+".", by),
|
"Requests, by "+description+".", by),
|
||||||
requestBytes: counterVec("smallwebwaf_"+label+"_request_bytes_total",
|
requestBytes: counterVec("sww_"+label+"_request_bytes_total",
|
||||||
"Request body bytes, by "+description+".", by),
|
"Request body bytes, by "+description+".", by),
|
||||||
responseBytes: counterVec("smallwebwaf_"+label+"_response_bytes_total",
|
responseBytes: counterVec("sww_"+label+"_response_bytes_total",
|
||||||
"Response body bytes, by "+description+".", by),
|
"Response body bytes, by "+description+".", by),
|
||||||
seen: map[string]int64{},
|
seen: map[string]int64{},
|
||||||
top: map[string]bool{},
|
top: map[string]bool{},
|
||||||
|
|||||||
+51
-51
@@ -1,4 +1,4 @@
|
|||||||
// Package metrics keeps smallwebwaf's Prometheus metrics, as
|
// Package metrics keeps sww's Prometheus metrics, as
|
||||||
// docs/metrics.md lists them, and serves them in the Prometheus text
|
// docs/metrics.md lists them, and serves them in the Prometheus text
|
||||||
// format, as the "Metrics endpoint" section of SPEC.md requires. No
|
// format, as the "Metrics endpoint" section of SPEC.md requires. No
|
||||||
// metric carries a client's address.
|
// metric carries a client's address.
|
||||||
@@ -22,7 +22,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Metrics are smallwebwaf's metrics. They are safe for concurrent use.
|
// Metrics are sww's metrics. They are safe for concurrent use.
|
||||||
type Metrics struct {
|
type Metrics struct {
|
||||||
// registry gives every metric registered with it the label instance.
|
// registry gives every metric registered with it the label instance.
|
||||||
registry prometheus.Registerer
|
registry prometheus.Registerer
|
||||||
@@ -62,8 +62,8 @@ type Metrics struct {
|
|||||||
|
|
||||||
// New returns the metrics, with the Go runtime's and the process's own.
|
// New returns the metrics, with the Go runtime's and the process's own.
|
||||||
// topN is how many countries and how many AS numbers get series of their
|
// topN is how many countries and how many AS numbers get series of their
|
||||||
// own (SWWAF_METRICS_TOP_N). Every metric carries instanceName
|
// own (SWW_METRICS_TOP_N). Every metric carries instanceName
|
||||||
// (SWWAF_INSTANCE_NAME) as its label instance.
|
// (SWW_INSTANCE_NAME) as its label instance.
|
||||||
//
|
//
|
||||||
//nolint:funlen // a few lines for each metric, a list that grows with them
|
//nolint:funlen // a few lines for each metric, a list that grows with them
|
||||||
func New(topN int, instanceName string) *Metrics {
|
func New(topN int, instanceName string) *Metrics {
|
||||||
@@ -76,60 +76,60 @@ func New(topN int, instanceName string) *Metrics {
|
|||||||
prometheus.Labels{"instance": instanceName}, registry),
|
prometheus.Labels{"instance": instanceName}, registry),
|
||||||
handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
|
handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
|
||||||
inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
|
inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_requests_in_flight",
|
Name: "sww_requests_in_flight",
|
||||||
Help: "Requests under way.",
|
Help: "Requests under way.",
|
||||||
}),
|
}),
|
||||||
requests: counterVec("smallwebwaf_requests_total",
|
requests: counterVec("sww_requests_total",
|
||||||
"Requests, by the class of their status and their action.", byStatus),
|
"Requests, by the class of their status and their action.", byStatus),
|
||||||
requestBytes: counterVec("smallwebwaf_request_bytes_total",
|
requestBytes: counterVec("sww_request_bytes_total",
|
||||||
"Request body bytes, by the class of the status and the action.",
|
"Request body bytes, by the class of the status and the action.",
|
||||||
byStatus),
|
byStatus),
|
||||||
responseBytes: counterVec("smallwebwaf_response_bytes_total",
|
responseBytes: counterVec("sww_response_bytes_total",
|
||||||
"Response body bytes, by the class of the status and the action.",
|
"Response body bytes, by the class of the status and the action.",
|
||||||
byStatus),
|
byStatus),
|
||||||
requestDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
requestDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||||
Name: "smallwebwaf_request_duration_seconds",
|
Name: "sww_request_duration_seconds",
|
||||||
Help: "How long requests took, from their arrival to their end.",
|
Help: "How long requests took, from their arrival to their end.",
|
||||||
}),
|
}),
|
||||||
upstreamDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
upstreamDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||||
Name: "smallwebwaf_upstream_duration_seconds",
|
Name: "sww_upstream_duration_seconds",
|
||||||
Help: "How long requests passed to the app took, from then to their end.",
|
Help: "How long requests passed to the app took, from then to their end.",
|
||||||
}),
|
}),
|
||||||
rateLimitHits: counterVec("smallwebwaf_rate_limit_hits_total",
|
rateLimitHits: counterVec("sww_rate_limit_hits_total",
|
||||||
"Requests that broke a rate limit, a byte limit or the error burst, by "+
|
"Requests that broke a rate limit, a byte limit or the error burst, by "+
|
||||||
"its window and its kind, requests, bytes or refusals.",
|
"its window and its kind, requests, bytes or refusals.",
|
||||||
[]string{"window", "kind"}),
|
[]string{"window", "kind"}),
|
||||||
offences: counterVec("smallwebwaf_offences_total",
|
offences: counterVec("sww_offences_total",
|
||||||
"Offences, by kind.", []string{"kind"}),
|
"Offences, by kind.", []string{"kind"}),
|
||||||
wafMatches: counterVec("smallwebwaf_waf_matches_total",
|
wafMatches: counterVec("sww_waf_matches_total",
|
||||||
"Requests that matched a rule of the Core Rule Set, by SWWAF_WAF_MODE "+
|
"Requests that matched a rule of the Core Rule Set, by SWW_WAF_MODE "+
|
||||||
"and the rule's id.", []string{"mode", "rule_id"}),
|
"and the rule's id.", []string{"mode", "rule_id"}),
|
||||||
countries: newCountries(topN),
|
countries: newCountries(topN),
|
||||||
asns: newASNs(topN),
|
asns: newASNs(topN),
|
||||||
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_geojs_requests_total",
|
Name: "sww_geojs_requests_total",
|
||||||
Help: "Requests to GeoJS.",
|
Help: "Requests to GeoJS.",
|
||||||
}),
|
}),
|
||||||
GeoJSFailures: prometheus.NewCounter(prometheus.CounterOpts{
|
GeoJSFailures: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_geojs_failures_total",
|
Name: "sww_geojs_failures_total",
|
||||||
Help: "Requests to GeoJS that failed.",
|
Help: "Requests to GeoJS that failed.",
|
||||||
}),
|
}),
|
||||||
GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{
|
GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_geojs_unanswered_total",
|
Name: "sww_geojs_unanswered_total",
|
||||||
Help: "Requests that needed their client's answer from GeoJS and " +
|
Help: "Requests that needed their client's answer from GeoJS and " +
|
||||||
"went on without it, because GeoJS had not given it in time.",
|
"went on without it, because GeoJS had not given it in time.",
|
||||||
}),
|
}),
|
||||||
stateFileWrites: counterVec("smallwebwaf_state_file_writes_total",
|
stateFileWrites: counterVec("sww_state_file_writes_total",
|
||||||
"Writes of each state file.", byFile),
|
"Writes of each state file.", byFile),
|
||||||
stateFileWriteFailures: counterVec("smallwebwaf_state_file_write_failures_total",
|
stateFileWriteFailures: counterVec("sww_state_file_write_failures_total",
|
||||||
"Writes of each state file that failed.", byFile),
|
"Writes of each state file that failed.", byFile),
|
||||||
stateFileLastWrite: gaugeVec("smallwebwaf_state_file_last_write_timestamp_seconds",
|
stateFileLastWrite: gaugeVec("sww_state_file_last_write_timestamp_seconds",
|
||||||
"When each state file was last written, in seconds since 1970.", byFile),
|
"When each state file was last written, in seconds since 1970.", byFile),
|
||||||
stateFileSize: gaugeVec("smallwebwaf_state_file_size_bytes",
|
stateFileSize: gaugeVec("sww_state_file_size_bytes",
|
||||||
"The size of each state file, as it was last written.", byFile),
|
"The size of each state file, as it was last written.", byFile),
|
||||||
stateFileEditsTakenIn: counterVec("smallwebwaf_state_file_edits_taken_in_total",
|
stateFileEditsTakenIn: counterVec("sww_state_file_edits_taken_in_total",
|
||||||
"Edits of each state file taken in while running.", byFile),
|
"Edits of each state file taken in while running.", byFile),
|
||||||
stateFileEditsSetAside: counterVec("smallwebwaf_state_file_edits_set_aside_total",
|
stateFileEditsSetAside: counterVec("sww_state_file_edits_set_aside_total",
|
||||||
"Edits of each state file renamed to <name>.bad because they did not parse.",
|
"Edits of each state file renamed to <name>.bad because they did not parse.",
|
||||||
byFile),
|
byFile),
|
||||||
}
|
}
|
||||||
@@ -161,7 +161,7 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin, bans.CauseCrowdSec,
|
bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin, bans.CauseCrowdSec,
|
||||||
} {
|
} {
|
||||||
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_bans_made_total",
|
Name: "sww_bans_made_total",
|
||||||
Help: "Bans made, by cause.",
|
Help: "Bans made, by cause.",
|
||||||
ConstLabels: prometheus.Labels{"cause": cause},
|
ConstLabels: prometheus.Labels{"cause": cause},
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
@@ -171,7 +171,7 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
|
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_active_bans",
|
Name: "sww_active_bans",
|
||||||
Help: "Bans active now, the permanent ones included.",
|
Help: "Bans active now, the permanent ones included.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
active, _ := ledger.Count(now())
|
active, _ := ledger.Count(now())
|
||||||
@@ -179,7 +179,7 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
return float64(active)
|
return float64(active)
|
||||||
}),
|
}),
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_permanent_bans",
|
Name: "sww_permanent_bans",
|
||||||
Help: "Permanent bans not lifted.",
|
Help: "Permanent bans not lifted.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
_, permanent := ledger.Count(now())
|
_, permanent := ledger.Count(now())
|
||||||
@@ -187,7 +187,7 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
return float64(permanent)
|
return float64(permanent)
|
||||||
}),
|
}),
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_tracked_clients",
|
Name: "sww_tracked_clients",
|
||||||
Help: "Clients in the table of clients.",
|
Help: "Clients in the table of clients.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(limiter.Len())
|
return float64(limiter.Len())
|
||||||
@@ -200,13 +200,13 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
// ruleFiles, read as the metrics are asked for. It is called once, before
|
// ruleFiles, read as the metrics are asked for. It is called once, before
|
||||||
// RuleMatched.
|
// RuleMatched.
|
||||||
func (m *Metrics) AddRules(ruleFiles *rules.Files) {
|
func (m *Metrics) AddRules(ruleFiles *rules.Files) {
|
||||||
m.ruleMatches = counterVec("smallwebwaf_rule_matches_total",
|
m.ruleMatches = counterVec("sww_rule_matches_total",
|
||||||
"Requests that matched a rule of the rule files, by its id and action.",
|
"Requests that matched a rule of the rule files, by its id and action.",
|
||||||
[]string{"rule_id", "action"})
|
[]string{"rule_id", "action"})
|
||||||
|
|
||||||
m.registry.MustRegister(m.ruleMatches,
|
m.registry.MustRegister(m.ruleMatches,
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_rules_loaded",
|
Name: "sww_rules_loaded",
|
||||||
Help: "Rules loaded from the rule files.",
|
Help: "Rules loaded from the rule files.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(ruleFiles.Len())
|
return float64(ruleFiles.Len())
|
||||||
@@ -214,25 +214,25 @@ func (m *Metrics) AddRules(ruleFiles *rules.Files) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddRemoteLog adds the metrics of sending the log lines to
|
// AddRemoteLog adds the metrics of sending the log lines to
|
||||||
// SWWAF_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
|
// SWW_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
|
||||||
// lines sent, those dropped, and those waiting in the buffer.
|
// lines sent, those dropped, and those waiting in the buffer.
|
||||||
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_remote_log_lines_sent_total",
|
Name: "sww_remote_log_lines_sent_total",
|
||||||
Help: "Log lines sent to SWWAF_LOG_REMOTE_URL.",
|
Help: "Log lines sent to SWW_LOG_REMOTE_URL.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(remote.Sent())
|
return float64(remote.Sent())
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_remote_log_lines_dropped_total",
|
Name: "sww_remote_log_lines_dropped_total",
|
||||||
Help: "Log lines dropped: the oldest in a full buffer, and those " +
|
Help: "Log lines dropped: the oldest in a full buffer, and those " +
|
||||||
"whose sending failed.",
|
"whose sending failed.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(remote.Dropped())
|
return float64(remote.Dropped())
|
||||||
}),
|
}),
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_remote_log_buffer_depth",
|
Name: "sww_remote_log_buffer_depth",
|
||||||
Help: "Log lines in the buffer, waiting to be sent.",
|
Help: "Log lines in the buffer, waiting to be sent.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(remote.Depth())
|
return float64(remote.Depth())
|
||||||
@@ -248,13 +248,13 @@ func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
|||||||
func (m *Metrics) AddLookupFile(lastRead func() time.Time, readFailures func() int) {
|
func (m *Metrics) AddLookupFile(lastRead func() time.Time, readFailures func() int) {
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_lookup_database_last_read_timestamp_seconds",
|
Name: "sww_lookup_database_last_read_timestamp_seconds",
|
||||||
Help: "When the lookup database in use was read, in seconds since 1970.",
|
Help: "When the lookup database in use was read, in seconds since 1970.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(lastRead().Unix())
|
return float64(lastRead().Unix())
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_lookup_database_read_failures_total",
|
Name: "sww_lookup_database_read_failures_total",
|
||||||
Help: "Replacements of the lookup database that could not be read.",
|
Help: "Replacements of the lookup database that could not be read.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(readFailures())
|
return float64(readFailures())
|
||||||
@@ -275,7 +275,7 @@ const sourceLabel = "source"
|
|||||||
// copy in use was fetched, and for a zone, the queries made and those that
|
// copy in use was fetched, and for a zone, the queries made and those that
|
||||||
// failed. It is called once, before ReputationHit.
|
// failed. It is called once, before ReputationHit.
|
||||||
func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) {
|
func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) {
|
||||||
m.reputationHits = counterVec("smallwebwaf_reputation_hits_total",
|
m.reputationHits = counterVec("sww_reputation_hits_total",
|
||||||
"Requests whose client a blocklist, the CrowdSec decision list, a DNSBL "+
|
"Requests whose client a blocklist, the CrowdSec decision list, a DNSBL "+
|
||||||
"zone or AbuseIPDB lists, by the list's URL, the zone, or abuseipdb.",
|
"zone or AbuseIPDB lists, by the list's URL, the zone, or abuseipdb.",
|
||||||
[]string{sourceLabel})
|
[]string{sourceLabel})
|
||||||
@@ -294,7 +294,7 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL
|
|||||||
m.addReputationFailures(source, func() int { return lists.Failures(listURL) })
|
m.addReputationFailures(source, func() int { return lists.Failures(listURL) })
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_reputation_last_fetch_timestamp_seconds",
|
Name: "sww_reputation_last_fetch_timestamp_seconds",
|
||||||
Help: "When the copy of the list in use was fetched, in seconds since " +
|
Help: "When the copy of the list in use was fetched, in seconds since " +
|
||||||
"1970, or 0 while there is none.",
|
"1970, or 0 while there is none.",
|
||||||
ConstLabels: source,
|
ConstLabels: source,
|
||||||
@@ -313,7 +313,7 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL
|
|||||||
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
|
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
|
||||||
// read from abuseIPDB as the metrics are asked for: the checks made, those
|
// read from abuseIPDB as the metrics are asked for: the checks made, those
|
||||||
// that failed, and how many checks the day's budget has left. It is
|
// that failed, and how many checks the day's budget has left. It is
|
||||||
// called once, after AddReputation, while SWWAF_ABUSEIPDB_KEY is set.
|
// called once, after AddReputation, while SWW_ABUSEIPDB_KEY is set.
|
||||||
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
||||||
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
|
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
|
||||||
|
|
||||||
@@ -321,8 +321,8 @@ func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
|||||||
m.addReputationFailures(source, abuseIPDB.Failures)
|
m.addReputationFailures(source, abuseIPDB.Failures)
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "smallwebwaf_reputation_daily_budget_remaining",
|
Name: "sww_reputation_daily_budget_remaining",
|
||||||
Help: "Checks of the day's SWWAF_ABUSEIPDB_DAILY_BUDGET not yet spent.",
|
Help: "Checks of the day's SWW_ABUSEIPDB_DAILY_BUDGET not yet spent.",
|
||||||
ConstLabels: source,
|
ConstLabels: source,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(abuseIPDB.BudgetLeft())
|
return float64(abuseIPDB.BudgetLeft())
|
||||||
@@ -348,29 +348,29 @@ func (m *Metrics) AddAlerts(queue *alerts.Queue) {
|
|||||||
|
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_alerts_sent_total",
|
Name: "sww_alerts_sent_total",
|
||||||
Help: "Alerts the destination took.",
|
Help: "Alerts the destination took.",
|
||||||
ConstLabels: destination,
|
ConstLabels: destination,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(queue.Counts(name).Sent)
|
return float64(queue.Counts(name).Sent)
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_alerts_failed_total",
|
Name: "sww_alerts_failed_total",
|
||||||
Help: "Requests to the destination that failed.",
|
Help: "Requests to the destination that failed.",
|
||||||
ConstLabels: destination,
|
ConstLabels: destination,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(queue.Counts(name).Failed)
|
return float64(queue.Counts(name).Failed)
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_alerts_suppressed_total",
|
Name: "sww_alerts_suppressed_total",
|
||||||
Help: "Alerts held back: repeats within SWWAF_ALERT_COOLDOWN, and " +
|
Help: "Alerts held back: repeats within SWW_ALERT_COOLDOWN, and " +
|
||||||
"alerts past SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary.",
|
"alerts past SWW_ALERT_MAX_PER_HOUR, for the hour's summary.",
|
||||||
ConstLabels: destination,
|
ConstLabels: destination,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(queue.Suppressed())
|
return float64(queue.Suppressed())
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_alerts_dropped_total",
|
Name: "sww_alerts_dropped_total",
|
||||||
Help: "Alerts dropped, the oldest first, from a full queue, and " +
|
Help: "Alerts dropped, the oldest first, from a full queue, and " +
|
||||||
"alerts given up as the destination refused them.",
|
"alerts given up as the destination refused them.",
|
||||||
ConstLabels: destination,
|
ConstLabels: destination,
|
||||||
@@ -442,7 +442,7 @@ func (m *Metrics) RuleMatched(id, action string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WAFMatched counts a request that matched the Core Rule Set's rule id,
|
// WAFMatched counts a request that matched the Core Rule Set's rule id,
|
||||||
// with SWWAF_WAF_MODE at mode.
|
// with SWW_WAF_MODE at mode.
|
||||||
func (m *Metrics) WAFMatched(mode string, id int) {
|
func (m *Metrics) WAFMatched(mode string, id int) {
|
||||||
m.wafMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
|
m.wafMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
|
||||||
}
|
}
|
||||||
@@ -467,7 +467,7 @@ func (m *Metrics) StateFileWritten(name string, size int, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// StateFileEditTakenIn counts an admin's edit of the state file name
|
// StateFileEditTakenIn counts an admin's edit of the state file name
|
||||||
// taken in while smallwebwaf runs.
|
// taken in while sww runs.
|
||||||
func (m *Metrics) StateFileEditTakenIn(name string) {
|
func (m *Metrics) StateFileEditTakenIn(name string) {
|
||||||
m.stateFileEditsTakenIn.WithLabelValues(name).Inc()
|
m.stateFileEditsTakenIn.WithLabelValues(name).Inc()
|
||||||
}
|
}
|
||||||
@@ -506,7 +506,7 @@ func (m *Metrics) count(
|
|||||||
// zone, or of the checks of clients with AbuseIPDB, which count tells.
|
// zone, or of the checks of clients with AbuseIPDB, which count tells.
|
||||||
func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) {
|
func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) {
|
||||||
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_reputation_queries_total",
|
Name: "sww_reputation_queries_total",
|
||||||
Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.",
|
Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.",
|
||||||
ConstLabels: source,
|
ConstLabels: source,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
@@ -519,7 +519,7 @@ func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() in
|
|||||||
// that failed, which count tells.
|
// that failed, which count tells.
|
||||||
func (m *Metrics) addReputationFailures(source prometheus.Labels, count func() int) {
|
func (m *Metrics) addReputationFailures(source prometheus.Labels, count func() int) {
|
||||||
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "smallwebwaf_reputation_failures_total",
|
Name: "sww_reputation_failures_total",
|
||||||
Help: "Fetches of the list, queries to the DNSBL zone, or checks with " +
|
Help: "Fetches of the list, queries to the DNSBL zone, or checks with " +
|
||||||
"AbuseIPDB, that failed.",
|
"AbuseIPDB, that failed.",
|
||||||
ConstLabels: source,
|
ConstLabels: source,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func TestHistoryKeepsEveryRequest(t *testing.T) {
|
|||||||
{Forwarded: true, Status: 502, ResponseBytes: 12},
|
{Forwarded: true, Status: 502, ResponseBytes: 12},
|
||||||
// Closed without an answer: refused, and no response.
|
// Closed without an answer: refused, and no response.
|
||||||
{Refused: true, Status: 0},
|
{Refused: true, Status: 0},
|
||||||
// Answered 404 for a path under /_smallwebwaf/: neither forwarded nor
|
// Answered 404 for a path under /_sww/: neither forwarded nor
|
||||||
// refused.
|
// refused.
|
||||||
{Status: 404},
|
{Status: 404},
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// and bytes counted over a minute, an hour and a day, as the "Counting
|
// and bytes counted over a minute, an hour and a day, as the "Counting
|
||||||
// method" section of SPEC.md describes, which tell when a request takes
|
// method" section of SPEC.md describes, which tell when a request takes
|
||||||
// the client over a rate limit or a byte limit, and each client's history
|
// the client over a rate limit or a byte limit, and each client's history
|
||||||
// since it was first seen. At most SWWAF_MAX_TRACKED_CLIENTS clients are
|
// since it was first seen. At most SWW_MAX_TRACKED_CLIENTS clients are
|
||||||
// kept, in memory, and written to clients.json and read from it by the
|
// kept, in memory, and written to clients.json and read from it by the
|
||||||
// state package.
|
// state package.
|
||||||
package ratelimit
|
package ratelimit
|
||||||
@@ -25,7 +25,7 @@ const (
|
|||||||
KindRequests = "requests"
|
KindRequests = "requests"
|
||||||
// KindBytes is a byte limit, on a client's bytes.
|
// KindBytes is a byte limit, on a client's bytes.
|
||||||
KindBytes = "bytes"
|
KindBytes = "bytes"
|
||||||
// KindRefusals is the error burst, on a client's requests smallwebwaf
|
// KindRefusals is the error burst, on a client's requests sww
|
||||||
// refused after a rule file match or for a missing or wrong token.
|
// refused after a rule file match or for a missing or wrong token.
|
||||||
KindRefusals = "refusals"
|
KindRefusals = "refusals"
|
||||||
)
|
)
|
||||||
@@ -92,9 +92,9 @@ type History struct {
|
|||||||
ASName string `json:"as_name,omitempty"`
|
ASName string `json:"as_name,omitempty"`
|
||||||
Country string `json:"country,omitempty"`
|
Country string `json:"country,omitempty"`
|
||||||
LookedUp time.Time `json:"looked_up,omitzero"`
|
LookedUp time.Time `json:"looked_up,omitzero"`
|
||||||
// Requests are all the client's requests: Forwarded those servewaf allowed,
|
// Requests are all the client's requests: Forwarded those discriminator allowed,
|
||||||
// Refused those it refused, and neither those for a path under
|
// Refused those it refused, and neither those for a path under
|
||||||
// /_smallwebwaf/.
|
// /_sww/.
|
||||||
Requests int64 `json:"requests"`
|
Requests int64 `json:"requests"`
|
||||||
Forwarded int64 `json:"forwarded"`
|
Forwarded int64 `json:"forwarded"`
|
||||||
Refused int64 `json:"refused"`
|
Refused int64 `json:"refused"`
|
||||||
@@ -135,8 +135,8 @@ type Offences struct {
|
|||||||
|
|
||||||
// Request is what a client's history keeps of one of its requests.
|
// Request is what a client's history keeps of one of its requests.
|
||||||
type Request struct {
|
type Request struct {
|
||||||
// Forwarded is true for a request servewaf 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 /_smallwebwaf/.
|
// refused. Both are false for a request for a path under /_sww/.
|
||||||
Forwarded bool
|
Forwarded bool
|
||||||
Refused bool
|
Refused bool
|
||||||
// Status is what the client was sent, 0 if nothing was.
|
// Status is what the client was sent, 0 if nothing was.
|
||||||
@@ -156,7 +156,7 @@ type Request struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New returns a Limiter for limits, with no client counted yet, whose
|
// New returns a Limiter for limits, with no client counted yet, whose
|
||||||
// table holds at most maxClients clients (SWWAF_MAX_TRACKED_CLIENTS). Past
|
// table holds at most maxClients clients (SWW_MAX_TRACKED_CLIENTS). Past
|
||||||
// it, the least recently seen client is dropped, with its history, and
|
// it, the least recently seen client is dropped, with its history, and
|
||||||
// starts afresh if it comes back.
|
// starts afresh if it comes back.
|
||||||
func New(limits Limits, maxClients int) *Limiter {
|
func New(limits Limits, maxClients int) *Limiter {
|
||||||
@@ -258,7 +258,7 @@ func (l *Limiter) CountBytes(
|
|||||||
return l.count(client, now, 0, bytes, percent)
|
return l.count(client, now, 0, bytes, percent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountRefusal counts a request from client at now that smallwebwaf
|
// CountRefusal counts a request from client at now that sww
|
||||||
// refused after a rule file or Core Rule Set match or for a missing or
|
// refused after a rule file or Core Rule Set match or for a missing or
|
||||||
// wrong token, and reports whether the client's refusals in the minute
|
// wrong token, and reports whether the client's refusals in the minute
|
||||||
// that ends at now, this one included, are more than threshold, which
|
// that ends at now, this one included, are more than threshold, which
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
const limit = 3
|
const limit = 3
|
||||||
|
|
||||||
// tableSize is the most clients the tests' tables hold, the default of
|
// tableSize is the most clients the tests' tables hold, the default of
|
||||||
// SWWAF_MAX_TRACKED_CLIENTS.
|
// SWW_MAX_TRACKED_CLIENTS.
|
||||||
const tableSize = 20000
|
const tableSize = 20000
|
||||||
|
|
||||||
// whole is the percentage of each limit a client gets when nothing lowers
|
// whole is the percentage of each limit a client gets when nothing lowers
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package remotelog sends the lines smallwebwaf writes on stdout to the
|
// Package remotelog sends the lines sww writes on stdout to the
|
||||||
// remote log endpoint, SWWAF_LOG_REMOTE_URL, as the "Request log" section
|
// remote log endpoint, SWW_LOG_REMOTE_URL, as the "Request log" section
|
||||||
// of SPEC.md describes: each line as the message of an RFC 5424 syslog
|
// of SPEC.md describes: each line as the message of an RFC 5424 syslog
|
||||||
// record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow
|
// record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow
|
||||||
// or unreachable endpoint never holds up a request or stdout.
|
// or unreachable endpoint never holds up a request or stdout.
|
||||||
@@ -24,7 +24,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The forms of SWWAF_LOG_REMOTE_URL, by its scheme.
|
// The forms of SWW_LOG_REMOTE_URL, by its scheme.
|
||||||
const (
|
const (
|
||||||
SchemeUDP = "syslog+udp"
|
SchemeUDP = "syslog+udp"
|
||||||
SchemeTCP = "syslog+tcp"
|
SchemeTCP = "syslog+tcp"
|
||||||
@@ -56,18 +56,18 @@ const (
|
|||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// URL is the endpoint (SWWAF_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or
|
// URL is the endpoint (SWW_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or
|
||||||
// SchemeTLS, a host and a port.
|
// SchemeTLS, a host and a port.
|
||||||
URL *url.URL
|
URL *url.URL
|
||||||
// RootCAs are the certificates a SchemeTLS endpoint's certificate
|
// RootCAs are the certificates a SchemeTLS endpoint's certificate
|
||||||
// must chain to (SWWAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
|
// must chain to (SWW_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
|
||||||
RootCAs *x509.CertPool
|
RootCAs *x509.CertPool
|
||||||
// Buffer is the most lines held while they wait to be sent
|
// Buffer is the most lines held while they wait to be sent
|
||||||
// (SWWAF_LOG_REMOTE_BUFFER).
|
// (SWW_LOG_REMOTE_BUFFER).
|
||||||
Buffer int
|
Buffer int
|
||||||
// Facility is the number of the records' syslog facility
|
// Facility is the number of the records' syslog facility
|
||||||
// (SWWAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME
|
// (SWW_LOG_REMOTE_FACILITY), and AppName their APP-NAME
|
||||||
// (SWWAF_LOG_REMOTE_APP_NAME).
|
// (SWW_LOG_REMOTE_APP_NAME).
|
||||||
Facility int
|
Facility int
|
||||||
AppName string
|
AppName string
|
||||||
}
|
}
|
||||||
@@ -243,7 +243,7 @@ func (s *Sender) send(ctx context.Context, processLog *slog.Logger) net.Conn {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
processLog.Warn("sending to SWWAF_LOG_REMOTE_URL failed",
|
processLog.Warn("sending to SWW_LOG_REMOTE_URL failed",
|
||||||
"error", err.Error(), "connecting_again_in", delay.String())
|
"error", err.Error(), "connecting_again_in", delay.String())
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -586,7 +586,7 @@ func wantRetries(t *testing.T, logged *output, delays ...string) {
|
|||||||
var fields map[string]any
|
var fields map[string]any
|
||||||
|
|
||||||
err := json.Unmarshal([]byte(line), &fields)
|
err := json.Unmarshal([]byte(line), &fields)
|
||||||
if err != nil || fields["msg"] != "sending to SWWAF_LOG_REMOTE_URL failed" {
|
if err != nil || fields["msg"] != "sending to SWW_LOG_REMOTE_URL failed" {
|
||||||
t.Fatalf("logged %q", line)
|
t.Fatalf("logged %q", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,7 +612,7 @@ func testCertificate(t *testing.T) (tls.Certificate, *x509.CertPool) {
|
|||||||
|
|
||||||
template := &x509.Certificate{
|
template := &x509.Certificate{
|
||||||
SerialNumber: big.NewInt(1),
|
SerialNumber: big.NewInt(1),
|
||||||
Subject: pkix.Name{CommonName: "smallwebwaf test CA"},
|
Subject: pkix.Name{CommonName: "sww test CA"},
|
||||||
NotBefore: time.Date(1999, 12, 31, 0, 0, 0, 0, time.UTC),
|
NotBefore: time.Date(1999, 12, 31, 0, 0, 0, 0, time.UTC),
|
||||||
NotAfter: time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC),
|
NotAfter: time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||||
IsCA: true,
|
IsCA: true,
|
||||||
|
|||||||
@@ -58,17 +58,17 @@ type Checks struct {
|
|||||||
// AbuseIPDBParams are what NewAbuseIPDB needs.
|
// AbuseIPDBParams are what NewAbuseIPDB needs.
|
||||||
type AbuseIPDBParams struct {
|
type AbuseIPDBParams struct {
|
||||||
// URL is where clients are checked, normally AbuseIPDBURL, with Key,
|
// URL is where clients are checked, normally AbuseIPDBURL, with Key,
|
||||||
// the account's key (SWWAF_ABUSEIPDB_KEY).
|
// the account's key (SWW_ABUSEIPDB_KEY).
|
||||||
URL string
|
URL string
|
||||||
Key string
|
Key string
|
||||||
// MinScore is the least score that is a hit (SWWAF_ABUSEIPDB_MIN_SCORE),
|
// MinScore is the least score that is a hit (SWW_ABUSEIPDB_MIN_SCORE),
|
||||||
// and DailyBudget the most checks made in a day, in UTC
|
// and DailyBudget the most checks made in a day, in UTC
|
||||||
// (SWWAF_ABUSEIPDB_DAILY_BUDGET).
|
// (SWW_ABUSEIPDB_DAILY_BUDGET).
|
||||||
MinScore int64
|
MinScore int64
|
||||||
DailyBudget int
|
DailyBudget int
|
||||||
// CacheTTL is how long a score is used after it was fetched
|
// CacheTTL is how long a score is used after it was fetched
|
||||||
// (SWWAF_REPUTATION_CACHE_TTL), and Timeout how long a check may take
|
// (SWW_REPUTATION_CACHE_TTL), and Timeout how long a check may take
|
||||||
// (SWWAF_REPUTATION_TIMEOUT).
|
// (SWW_REPUTATION_TIMEOUT).
|
||||||
CacheTTL time.Duration
|
CacheTTL time.Duration
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Now tells the time, normally time.Now in UTC.
|
// Now tells the time, normally time.Now in UTC.
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ func TestMetricsCountTheChecksTheFailuresAndTheBudgetLeft(t *testing.T) {
|
|||||||
"failures_total": "1",
|
"failures_total": "1",
|
||||||
"daily_budget_remaining": "3",
|
"daily_budget_remaining": "3",
|
||||||
} {
|
} {
|
||||||
line := "\nsmallwebwaf_reputation_" + series +
|
line := "\nsww_reputation_" + series +
|
||||||
`{instance="app",source="abuseipdb"} ` + want + "\n"
|
`{instance="app",source="abuseipdb"} ` + want + "\n"
|
||||||
if !strings.Contains(scraped, line) {
|
if !strings.Contains(scraped, line) {
|
||||||
t.Errorf("metrics\n%s\nwant%s", scraped, line)
|
t.Errorf("metrics\n%s\nwant%s", scraped, line)
|
||||||
@@ -581,7 +581,7 @@ func wantScore(
|
|||||||
|
|
||||||
// hitFrom is checker's Hit for a request from address, offender or not.
|
// hitFrom is checker's Hit for a request from address, offender or not.
|
||||||
// Its client is address for an IPv4 address, and its /64 for an IPv6 one,
|
// Its client is address for an IPv4 address, and its /64 for an IPv6 one,
|
||||||
// as smallwebwaf counts clients.
|
// as sww counts clients.
|
||||||
func hitFrom(
|
func hitFrom(
|
||||||
t *testing.T, checker *reputation.AbuseIPDB, address string, offender bool,
|
t *testing.T, checker *reputation.AbuseIPDB, address string, offender bool,
|
||||||
) (int64, bool) {
|
) (int64, bool) {
|
||||||
|
|||||||
@@ -53,15 +53,15 @@ type Verdict struct {
|
|||||||
// DNSBLParams are what NewDNSBL needs.
|
// DNSBLParams are what NewDNSBL needs.
|
||||||
type DNSBLParams struct {
|
type DNSBLParams struct {
|
||||||
// Zones are the DNSBL zones clients are asked about in
|
// Zones are the DNSBL zones clients are asked about in
|
||||||
// (SWWAF_DNSBL_ZONES).
|
// (SWW_DNSBL_ZONES).
|
||||||
Zones []string
|
Zones []string
|
||||||
// Resolver is the resolver they are asked through
|
// Resolver is the resolver they are asked through
|
||||||
// (SWWAF_DNSBL_RESOLVER), or, while it is the zero AddrPort, the
|
// (SWW_DNSBL_RESOLVER), or, while it is the zero AddrPort, the
|
||||||
// host's, as /etc/resolv.conf names it.
|
// host's, as /etc/resolv.conf names it.
|
||||||
Resolver netip.AddrPort
|
Resolver netip.AddrPort
|
||||||
// CacheTTL is how long a verdict is used after it was fetched
|
// CacheTTL is how long a verdict is used after it was fetched
|
||||||
// (SWWAF_REPUTATION_CACHE_TTL), and Timeout how long a query may take
|
// (SWW_REPUTATION_CACHE_TTL), and Timeout how long a query may take
|
||||||
// (SWWAF_REPUTATION_TIMEOUT).
|
// (SWW_REPUTATION_TIMEOUT).
|
||||||
CacheTTL time.Duration
|
CacheTTL time.Duration
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Now tells the time, normally time.Now in UTC.
|
// Now tells the time, normally time.Now in UTC.
|
||||||
@@ -127,13 +127,13 @@ func NewDNSBL(params DNSBLParams) *DNSBL {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zones returns the zones, in the order SWWAF_DNSBL_ZONES names them.
|
// Zones returns the zones, in the order SWW_DNSBL_ZONES names them.
|
||||||
func (d *DNSBL) Zones() []string {
|
func (d *DNSBL) Zones() []string {
|
||||||
return slices.Clone(d.params.Zones)
|
return slices.Clone(d.params.Zones)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListedBy returns the zones whose verdict on addr, a client's address,
|
// ListedBy returns the zones whose verdict on addr, a client's address,
|
||||||
// lists it, in the order SWWAF_DNSBL_ZONES names them, each with its key
|
// lists it, in the order SWW_DNSBL_ZONES names them, each with its key
|
||||||
// masked, as config.MaskZoneKey masks it, since they go to the request
|
// masked, as config.MaskZoneKey masks it, since they go to the request
|
||||||
// log, the alerts and the metrics. A verdict is used until CacheTTL has
|
// log, the alerts and the metrics. A verdict is used until CacheTTL has
|
||||||
// passed since it was fetched. Each zone without one is asked about addr
|
// passed since it was fetched. Each zone without one is asked about addr
|
||||||
@@ -210,7 +210,7 @@ func (d *DNSBL) Snapshot() []Verdict {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load keeps verdicts, read from reputation.json, in place of those it
|
// Load keeps verdicts, read from reputation.json, in place of those it
|
||||||
// keeps, but for those of a zone SWWAF_DNSBL_ZONES does not name, and,
|
// keeps, but for those of a zone SWW_DNSBL_ZONES does not name, and,
|
||||||
// past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or
|
// past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or
|
||||||
// more is neither used nor written, as for any verdict.
|
// more is neither used nor written, as for any verdict.
|
||||||
func (d *DNSBL) Load(verdicts []Verdict) {
|
func (d *DNSBL) Load(verdicts []Verdict) {
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ const (
|
|||||||
// zone and otherZone are the DNSBL zones the tests name.
|
// zone and otherZone are the DNSBL zones the tests name.
|
||||||
zone = "dnsbl.example"
|
zone = "dnsbl.example"
|
||||||
otherZone = "other.example"
|
otherZone = "other.example"
|
||||||
// cacheTTL is the tests' SWWAF_REPUTATION_CACHE_TTL, and timeout their
|
// cacheTTL is the tests' SWW_REPUTATION_CACHE_TTL, and timeout their
|
||||||
// SWWAF_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
|
// SWW_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
|
||||||
// can have Go's resolver wait for one server, so that it is the
|
// can have Go's resolver wait for one server, so that it is the
|
||||||
// DNSBL's own timeout that ends a query, whatever that file says.
|
// DNSBL's own timeout that ends a query, whatever that file says.
|
||||||
cacheTTL = 24 * time.Hour
|
cacheTTL = 24 * time.Hour
|
||||||
@@ -311,7 +311,7 @@ func TestMetricsCountEachZonesQueriesAndThoseThatFailed(t *testing.T) {
|
|||||||
"queries_total" + `{instance="app",source="` + otherZone + `"}`: "1",
|
"queries_total" + `{instance="app",source="` + otherZone + `"}`: "1",
|
||||||
"failures_total" + `{instance="app",source="` + otherZone + `"}`: "1",
|
"failures_total" + `{instance="app",source="` + otherZone + `"}`: "1",
|
||||||
} {
|
} {
|
||||||
line := "\nsmallwebwaf_reputation_" + series + " " + want + "\n"
|
line := "\nsww_reputation_" + series + " " + want + "\n"
|
||||||
if !strings.Contains(scraped.Body.String(), line) {
|
if !strings.Contains(scraped.Body.String(), line) {
|
||||||
t.Errorf("metrics\n%s\nwant%s", scraped.Body.String(), line)
|
t.Errorf("metrics\n%s\nwant%s", scraped.Body.String(), line)
|
||||||
}
|
}
|
||||||
@@ -419,7 +419,7 @@ func TestNeitherAVerdictOfAZoneNotNamedNorOnePastItsTTLIsKept(t *testing.T) {
|
|||||||
dnsbl := reputation.NewDNSBL(p)
|
dnsbl := reputation.NewDNSBL(p)
|
||||||
|
|
||||||
// The last verdict still in use, one fetched a TTL ago, and one of a
|
// The last verdict still in use, one fetched a TTL ago, and one of a
|
||||||
// zone SWWAF_DNSBL_ZONES does not name.
|
// zone SWW_DNSBL_ZONES does not name.
|
||||||
inUse := reputation.Verdict{
|
inUse := reputation.Verdict{
|
||||||
Zone: zone, Client: netip.MustParseAddr(listed), Listed: true,
|
Zone: zone, Client: netip.MustParseAddr(listed), Listed: true,
|
||||||
Fetched: now.Add(-cacheTTL + time.Nanosecond),
|
Fetched: now.Add(-cacheTTL + time.Nanosecond),
|
||||||
@@ -471,7 +471,7 @@ func TestAtMost100000VerdictsKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // one at a time, as the comment at the top of this file says
|
//nolint:paralleltest // one at a time, as the comment at the top of this file says
|
||||||
func TestQueriesGoToTheResolverSWWAFDNSBLResolverNames(t *testing.T) {
|
func TestQueriesGoToTheResolverSWWDNSBLResolverNames(t *testing.T) {
|
||||||
resolver := &resolverStandIn{answers: map[string]answer{
|
resolver := &resolverStandIn{answers: map[string]answer{
|
||||||
listedName: {addrs: []string{listing}},
|
listedName: {addrs: []string{listing}},
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// Package reputation fetches the lists the settings name by URL: the
|
// Package reputation fetches the lists the settings name by URL: the
|
||||||
// blocklists of SWWAF_BLOCKLIST_URLS, the file of AS:percent lines
|
// blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines
|
||||||
// SWWAF_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec
|
// SWW_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec
|
||||||
// engine SWWAF_CROWDSEC_LAPI_URL names. It keeps the last good copy of
|
// engine SWW_CROWDSEC_LAPI_URL names. It keeps the last good copy of
|
||||||
// each, whole, comment lines included, which is used while a fetch fails,
|
// each, whole, comment lines included, which is used while a fetch fails,
|
||||||
// and when each was last tried. It also asks the DNSBL zones of
|
// and when each was last tried. It also asks the DNSBL zones of
|
||||||
// SWWAF_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
// SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
||||||
// clients with AbuseIPDB, and keeps their scores and the checks spent
|
// clients with AbuseIPDB, and keeps their scores and the checks spent
|
||||||
// today. The state package writes all of these to reputation.json and
|
// today. The state package writes all of these to reputation.json and
|
||||||
// reads them from it, so that a restart keeps them too.
|
// reads them from it, so that a restart keeps them too.
|
||||||
@@ -66,18 +66,18 @@ type List struct {
|
|||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// BlocklistURLs are the blocklists (SWWAF_BLOCKLIST_URLS), and
|
// BlocklistURLs are the blocklists (SWW_BLOCKLIST_URLS), and
|
||||||
// ASNLimitPercentURL the file of AS:percent lines
|
// ASNLimitPercentURL the file of AS:percent lines
|
||||||
// (SWWAF_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
// (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
||||||
BlocklistURLs []string
|
BlocklistURLs []string
|
||||||
ASNLimitPercentURL string
|
ASNLimitPercentURL string
|
||||||
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
|
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
|
||||||
// SWWAF_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
// SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
||||||
// (SWWAF_CROWDSEC_LAPI_KEY).
|
// (SWW_CROWDSEC_LAPI_KEY).
|
||||||
CrowdSecDecisionsURL string
|
CrowdSecDecisionsURL string
|
||||||
CrowdSecKey string
|
CrowdSecKey string
|
||||||
// Refresh is how long after a list was last fetched or tried it is
|
// Refresh is how long after a list was last fetched or tried it is
|
||||||
// fetched again (SWWAF_BLOCKLIST_REFRESH), but for the CrowdSec decision
|
// fetched again (SWW_BLOCKLIST_REFRESH), but for the CrowdSec decision
|
||||||
// list, which is fetched again crowdSecRefresh after.
|
// list, which is fetched again crowdSecRefresh after.
|
||||||
Refresh time.Duration
|
Refresh time.Duration
|
||||||
// Now tells the time, normally time.Now in UTC.
|
// Now tells the time, normally time.Now in UTC.
|
||||||
@@ -152,7 +152,7 @@ func New(params Params) *Lists {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// URLs returns the URL of every list: the blocklists' in the order
|
// URLs returns the URL of every list: the blocklists' in the order
|
||||||
// SWWAF_BLOCKLIST_URLS names them, then SWWAF_ASN_LIMIT_PERCENT_URL, then
|
// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then
|
||||||
// the CrowdSec decision list's.
|
// the CrowdSec decision list's.
|
||||||
func (l *Lists) URLs() []string {
|
func (l *Lists) URLs() []string {
|
||||||
urls := slices.Clone(l.params.BlocklistURLs)
|
urls := slices.Clone(l.params.BlocklistURLs)
|
||||||
@@ -168,7 +168,7 @@ func (l *Lists) URLs() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListedBy returns the URLs of the blocklists whose copy lists addr, in
|
// ListedBy returns the URLs of the blocklists whose copy lists addr, in
|
||||||
// the order SWWAF_BLOCKLIST_URLS names them.
|
// the order SWW_BLOCKLIST_URLS names them.
|
||||||
func (l *Lists) ListedBy(addr netip.Addr) []string {
|
func (l *Lists) ListedBy(addr netip.Addr) []string {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
@@ -371,7 +371,7 @@ func (l *Lists) due(listURL string) time.Time {
|
|||||||
// fetch fetches the list at listURL, and notes the try. A good copy takes
|
// fetch fetches the list at listURL, and notes the try. A good copy takes
|
||||||
// the place of the one held. A failure leaves that in use, and is counted,
|
// the place of the one held. A failure leaves that in use, and is counted,
|
||||||
// logged and raised as a source_failure alert. A fetch cut off as ctx
|
// logged and raised as a source_failure alert. A fetch cut off as ctx
|
||||||
// ends, as smallwebwaf stops, is no failure, but is still noted as a try,
|
// ends, as sww stops, is no failure, but is still noted as a try,
|
||||||
// so that a restart waits for it: the server may have had its request.
|
// so that a restart waits for it: the server may have had its request.
|
||||||
func (l *Lists) fetch(ctx context.Context, listURL string) {
|
func (l *Lists) fetch(ctx context.Context, listURL string) {
|
||||||
lines, err := l.get(ctx, listURL)
|
lines, err := l.get(ctx, listURL)
|
||||||
@@ -551,7 +551,7 @@ func parseNetblock(text string) (netip.Prefix, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parsePercents reads the lines of the file of AS:percent lines, each an
|
// parsePercents reads the lines of the file of AS:percent lines, each an
|
||||||
// AS number, : and a percentage, as SWWAF_ASN_LIMIT_PERCENT takes them. An
|
// AS number, : and a percentage, as SWW_ASN_LIMIT_PERCENT takes them. An
|
||||||
// AS number listed more than once gets the lowest of its percentages.
|
// AS number listed more than once gets the lowest of its percentages.
|
||||||
func parsePercents(lines []string) (entries, error) {
|
func parsePercents(lines []string) (entries, error) {
|
||||||
found := entries{percents: map[string]int64{}}
|
found := entries{percents: map[string]int64{}}
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ const (
|
|||||||
dropURL = "https://lists.example/drop.txt"
|
dropURL = "https://lists.example/drop.txt"
|
||||||
torURL = "https://lists.example/tor.txt"
|
torURL = "https://lists.example/tor.txt"
|
||||||
asnURL = "https://lists.example/asn.txt"
|
asnURL = "https://lists.example/asn.txt"
|
||||||
// refresh is the tests' SWWAF_BLOCKLIST_REFRESH, and cooldown their
|
// refresh is the tests' SWW_BLOCKLIST_REFRESH, and cooldown their
|
||||||
// SWWAF_ALERT_COOLDOWN, longer than it.
|
// SWW_ALERT_COOLDOWN, longer than it.
|
||||||
refresh = 24 * time.Hour
|
refresh = 24 * time.Hour
|
||||||
cooldown = 48 * time.Hour
|
cooldown = 48 * time.Hour
|
||||||
// drop is a blocklist as the Spamhaus DROP list is written, with an
|
// drop is a blocklist as the Spamhaus DROP list is written, with an
|
||||||
@@ -116,7 +116,7 @@ func TestClientIsListedByEachBlocklistThatListsIt(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
lists := start(t, servers, params(torURL, dropURL))
|
lists := start(t, servers, params(torURL, dropURL))
|
||||||
|
|
||||||
// In the order SWWAF_BLOCKLIST_URLS names them.
|
// In the order SWW_BLOCKLIST_URLS names them.
|
||||||
wantListedBy(t, lists, "203.0.113.9", torURL, dropURL)
|
wantListedBy(t, lists, "203.0.113.9", torURL, dropURL)
|
||||||
wantListedBy(t, lists, "203.0.113.8", dropURL)
|
wantListedBy(t, lists, "203.0.113.8", dropURL)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Package requestlog writes the lines smallwebwaf prints on stdout: one
|
// Package requestlog writes the lines sww prints on stdout: one
|
||||||
// JSON object per request, two from servewaf and a short one from rproxy, marked
|
// JSON object per request, two from discriminator and a short one from rproxy,
|
||||||
// "type":"request", and the process's own messages as JSON lines marked
|
// marked "type":"request", and the process's own messages as JSON lines marked
|
||||||
// "type":"process".
|
// "type":"process".
|
||||||
package requestlog
|
package requestlog
|
||||||
|
|
||||||
@@ -14,10 +14,10 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The action a request line names: what servewaf did with the request.
|
// The action a request line names: what discriminator did with the request.
|
||||||
const (
|
const (
|
||||||
// ActionForward is a request servewaf allowed, which rproxy passes to the
|
// ActionForward is a request discriminator allowed, which rproxy passes to
|
||||||
// app.
|
// the app.
|
||||||
ActionForward = "forward"
|
ActionForward = "forward"
|
||||||
// ActionTooLarge is a request over the header limits.
|
// ActionTooLarge is a request over the header limits.
|
||||||
ActionTooLarge = "too_large"
|
ActionTooLarge = "too_large"
|
||||||
@@ -33,17 +33,17 @@ const (
|
|||||||
// rule.
|
// rule.
|
||||||
ActionRuleBlocked = "rule_blocked"
|
ActionRuleBlocked = "rule_blocked"
|
||||||
// ActionWAFBlocked is a request refused because the Core Rule Set
|
// ActionWAFBlocked is a request refused because the Core Rule Set
|
||||||
// scored it at or over SWWAF_WAF_ANOMALY_THRESHOLD.
|
// scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
|
||||||
ActionWAFBlocked = "waf_blocked"
|
ActionWAFBlocked = "waf_blocked"
|
||||||
// ActionDenied is a request refused because its client is in
|
// ActionDenied is a request refused because its client is in
|
||||||
// SWWAF_DENY_NETS, in a blocklist while SWWAF_BLOCKLIST_ACTION is deny,
|
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
|
||||||
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
||||||
// SWWAF_REPUTATION_ACTION is deny.
|
// SWW_REPUTATION_ACTION is deny.
|
||||||
ActionDenied = "denied"
|
ActionDenied = "denied"
|
||||||
// ActionCountryDenied is a request refused for its client's country.
|
// ActionCountryDenied is a request refused for its client's country.
|
||||||
ActionCountryDenied = "country_denied"
|
ActionCountryDenied = "country_denied"
|
||||||
// ActionAdmin is a request servewaf answered at one of its own endpoints,
|
// ActionAdmin is a request discriminator answered at one of its own
|
||||||
// or a question about a path under /_smallwebwaf/.
|
// endpoints, or a question about a path under /_sww/.
|
||||||
ActionAdmin = "admin"
|
ActionAdmin = "admin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,14 +56,14 @@ const OffenceLimit = "limit"
|
|||||||
// that broke the error burst.
|
// that broke the error burst.
|
||||||
const LimitHitErrorBurst = "error_burst"
|
const LimitHitErrorBurst = "error_burst"
|
||||||
|
|
||||||
// The servewaf_line a request line names, which says which of servewaf's two
|
// The discriminator_line a request line names, which says which of
|
||||||
// lines about a question it is.
|
// discriminator's two lines about a question it is.
|
||||||
const (
|
const (
|
||||||
// ServeWAFLineDecision is servewaf's line at its decision.
|
// DiscriminatorLineDecision is discriminator's line at its decision.
|
||||||
ServeWAFLineDecision = "decision"
|
DiscriminatorLineDecision = "decision"
|
||||||
// ServeWAFLineReport is servewaf's line when rproxy's report comes, with
|
// DiscriminatorLineReport is discriminator's line when rproxy's report
|
||||||
// the decision's fields and the report's together.
|
// comes, with the decision's fields and the report's together.
|
||||||
ServeWAFLineReport = "report"
|
DiscriminatorLineReport = "report"
|
||||||
)
|
)
|
||||||
|
|
||||||
// timeLayout is RFC 3339 with milliseconds.
|
// timeLayout is RFC 3339 with milliseconds.
|
||||||
@@ -76,9 +76,9 @@ const timeLayout = "2006-01-02T15:04:05.000Z07:00"
|
|||||||
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
|
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
|
||||||
type Line struct {
|
type Line struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
// ServeWAFLine is ServeWAFLineDecision or ServeWAFLineReport on servewaf's
|
// DiscriminatorLine is DiscriminatorLineDecision or DiscriminatorLineReport
|
||||||
// two lines about a question.
|
// on discriminator's two lines about a question.
|
||||||
ServeWAFLine string `json:"servewaf_line,omitempty"`
|
DiscriminatorLine string `json:"discriminator_line,omitempty"`
|
||||||
|
|
||||||
// The standard web log fields. Scheme is how the client reached rproxy, or
|
// The standard web log fields. Scheme is how the client reached rproxy, or
|
||||||
// the trusted proxy in front of it.
|
// the trusted proxy in front of it.
|
||||||
@@ -98,7 +98,7 @@ type Line struct {
|
|||||||
UserAgent string `json:"user_agent"`
|
UserAgent string `json:"user_agent"`
|
||||||
|
|
||||||
// Request detail. RequestID is the id rproxy made for the request, or for a
|
// Request detail. RequestID is the id rproxy made for the request, or for a
|
||||||
// request to servewaf'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
|
// X-Forwarded-For header as received. ClientGroup is the netblock the
|
||||||
// client is counted as. ASN, ASName and Country are the client's AS
|
// client is counted as. ASN, ASName and Country are the client's AS
|
||||||
// number, AS name and country, as looked up.
|
// number, AS name and country, as looked up.
|
||||||
@@ -112,14 +112,14 @@ type Line struct {
|
|||||||
ContentType string `json:"content_type,omitempty"`
|
ContentType string `json:"content_type,omitempty"`
|
||||||
// ContentLength is the length of its body the request announced.
|
// ContentLength is the length of its body the request announced.
|
||||||
ContentLength int64 `json:"content_length,omitempty"`
|
ContentLength int64 `json:"content_length,omitempty"`
|
||||||
// RequestHeaders are the headers SWWAF_LOG_REQUEST_HEADERS names that
|
// RequestHeaders are the headers SWW_LOG_REQUEST_HEADERS names that
|
||||||
// the request carried, by name in lower case.
|
// the request carried, by name in lower case.
|
||||||
RequestHeaders map[string]string `json:"request_headers,omitempty"`
|
RequestHeaders map[string]string `json:"request_headers,omitempty"`
|
||||||
HasAuthorization bool `json:"has_authorization,omitempty"`
|
HasAuthorization bool `json:"has_authorization,omitempty"`
|
||||||
HasCookie bool `json:"has_cookie,omitempty"`
|
HasCookie bool `json:"has_cookie,omitempty"`
|
||||||
|
|
||||||
// Response detail, of servewaf's own answer. Aborted is true when rproxy, or
|
// Response detail, of discriminator's own answer. Aborted is true when
|
||||||
// the admin's tool, went away before the answer.
|
// rproxy, or the admin's tool, went away before the answer.
|
||||||
ResponseContentType string `json:"response_content_type,omitempty"`
|
ResponseContentType string `json:"response_content_type,omitempty"`
|
||||||
Aborted bool `json:"aborted,omitempty"`
|
Aborted bool `json:"aborted,omitempty"`
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ type Line struct {
|
|||||||
// WouldAction is, in observe mode, the action enforce mode would have
|
// WouldAction is, in observe mode, the action enforce mode would have
|
||||||
// taken with a request it would have refused: ActionDenied,
|
// taken with a request it would have refused: ActionDenied,
|
||||||
// ActionBanned, ActionCountryDenied, ActionRateLimited,
|
// ActionBanned, ActionCountryDenied, ActionRateLimited,
|
||||||
// ActionRuleBlocked, ActionWAFBlocked, or, in servewaf, ActionTooLarge
|
// ActionRuleBlocked, ActionWAFBlocked, or, in discriminator, ActionTooLarge
|
||||||
// for the header limits.
|
// for the header limits.
|
||||||
WouldAction string `json:"would_action,omitempty"`
|
WouldAction string `json:"would_action,omitempty"`
|
||||||
// LimitPercent and LimitPercentSetting are, for a request the rate
|
// LimitPercent and LimitPercentSetting are, for a request the rate
|
||||||
@@ -201,7 +201,7 @@ type RProxyLine struct {
|
|||||||
// if that is sooner. DurationResponse runs from then until the answer ends.
|
// if that is sooner. DurationResponse runs from then until the answer ends.
|
||||||
DurationRequest float64 `json:"duration_request"`
|
DurationRequest float64 `json:"duration_request"`
|
||||||
DurationResponse float64 `json:"duration_response"`
|
DurationResponse float64 `json:"duration_response"`
|
||||||
// Disposition is the valid disposition servewaf answered or, while
|
// Disposition is the valid disposition discriminator answered or, while
|
||||||
// DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is ""
|
// DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is ""
|
||||||
// when neither gave one.
|
// when neither gave one.
|
||||||
Disposition string `json:"disposition,omitempty"`
|
Disposition string `json:"disposition,omitempty"`
|
||||||
@@ -250,8 +250,8 @@ func Milliseconds(d time.Duration) float64 {
|
|||||||
|
|
||||||
// NewProcessLogger returns the logger for the process's own messages:
|
// NewProcessLogger returns the logger for the process's own messages:
|
||||||
// JSON lines on w, marked "type":"process", with the time in the same form
|
// JSON lines on w, marked "type":"process", with the time in the same form
|
||||||
// as a request line's, and instanceName, SWWAF_INSTANCE_NAME, as instance.
|
// as a request line's, and instanceName, SWW_INSTANCE_NAME, as instance.
|
||||||
// It writes only the messages at level, SWWAF_LOG_LEVEL, or more severe;
|
// It writes only the messages at level, SWW_LOG_LEVEL, or more severe;
|
||||||
// the request lines Write writes are never held back.
|
// the request lines Write writes are never held back.
|
||||||
func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger {
|
func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger {
|
||||||
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// peerAddress is the address of the request's TCP peer, normally traefik, without
|
// peerAddress is the address of the request's TCP peer, normally traefik, without
|
||||||
// any IPv6 zone, which servewaf refuses.
|
// any IPv6 zone, which discriminator refuses.
|
||||||
func peerAddress(r *http.Request) netip.Addr {
|
func peerAddress(r *http.Request) netip.Addr {
|
||||||
addrPort, err := netip.ParseAddrPort(r.RemoteAddr)
|
addrPort, err := netip.ParseAddrPort(r.RemoteAddr)
|
||||||
if err != nil {
|
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
|
// 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
|
// 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
|
// ends the reading, since nothing to its left can be believed. The visitor's
|
||||||
// address never has an IPv6 zone, which servewaf refuses.
|
// address never has an IPv6 zone, which discriminator refuses.
|
||||||
func clientAddress(
|
func clientAddress(
|
||||||
peer netip.Addr, forwardedFor []string, trusted []netip.Prefix,
|
peer netip.Addr, forwardedFor []string, trusted []netip.Prefix,
|
||||||
) netip.Addr {
|
) netip.Addr {
|
||||||
|
|||||||
@@ -11,15 +11,15 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
)
|
)
|
||||||
|
|
||||||
// answerBodyMaxBytes is the most of the body of servewaf'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.
|
// It is far above any answer's.
|
||||||
const answerBodyMaxBytes = 1 << 20
|
const answerBodyMaxBytes = 1 << 20
|
||||||
|
|
||||||
// reportAnswerMaxBytes is the most of the answer to a report that is read,
|
// reportAnswerMaxBytes is the most of the answer to a report that is read,
|
||||||
// which has no body when servewaf takes the report.
|
// which has no body when discriminator takes the report.
|
||||||
const reportAnswerMaxBytes = 4 << 10
|
const reportAnswerMaxBytes = 4 << 10
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -27,21 +27,22 @@ var (
|
|||||||
errReportRefused = errors.New("the report was answered")
|
errReportRefused = errors.New("the report was answered")
|
||||||
)
|
)
|
||||||
|
|
||||||
// servewafAnswer is what servewaf answered to a question.
|
// discriminatorAnswer is what discriminator answered to a question.
|
||||||
type servewafAnswer struct {
|
type discriminatorAnswer struct {
|
||||||
status int
|
status int
|
||||||
header http.Header
|
header http.Header
|
||||||
body []byte
|
body []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// noteAnswer notes what servewaf answered. An answer without a valid disposition
|
// noteAnswer notes what discriminator answered. An answer without a valid disposition
|
||||||
// notes nothing, and no report is due for it: servewaf holds no decision to report
|
// notes nothing, and no report is due for it: discriminator holds no decision to report
|
||||||
// on.
|
// on.
|
||||||
func (rq *request) noteAnswer(answer *servewafAnswer) {
|
func (rq *request) noteAnswer(answer *discriminatorAnswer) {
|
||||||
disposition := answer.header.Get(servewaf.DispositionHeader)
|
disposition := answer.header.Get(discriminator.DispositionHeader)
|
||||||
|
|
||||||
switch disposition {
|
switch disposition {
|
||||||
case servewaf.DispositionAllow, servewaf.DispositionDeny, servewaf.DispositionClose:
|
case discriminator.DispositionAllow, discriminator.DispositionDeny,
|
||||||
|
discriminator.DispositionClose:
|
||||||
rq.disposition = disposition
|
rq.disposition = disposition
|
||||||
rq.answered = true
|
rq.answered = true
|
||||||
rq.asn = answer.header.Get(asnHeader)
|
rq.asn = answer.header.Get(asnHeader)
|
||||||
@@ -54,27 +55,27 @@ func (rq *request) noteAnswer(answer *servewafAnswer) {
|
|||||||
func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
||||||
switch {
|
switch {
|
||||||
case ctx.Err() != nil:
|
case ctx.Err() != nil:
|
||||||
// The visitor went away while servewaf was being asked. servewaf 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
|
// failed, and nothing is noted. This panic has Go's server close the
|
||||||
// connection without an answer, and log nothing; the deferred finish still
|
// connection without an answer, and log nothing; the deferred finish still
|
||||||
// runs.
|
// runs.
|
||||||
panic(http.ErrAbortHandler)
|
panic(http.ErrAbortHandler)
|
||||||
case errors.Is(err, errCannotPassOn):
|
case errors.Is(err, errCannotPassOn):
|
||||||
rq.h.processLog.Warn("servewaf's answer is dropped", "error", err.Error())
|
rq.h.processLog.Warn("discriminator's answer is dropped", "error", err.Error())
|
||||||
rq.answer(http.StatusBadGateway)
|
rq.answer(http.StatusBadGateway)
|
||||||
default:
|
default:
|
||||||
// servewaf cannot be connected to, does not answer at all, or does not answer
|
// discriminator cannot be connected to, does not answer at all, or does not answer
|
||||||
// within SWRP_TIMEOUT_MS. A request that keeps servewaf busy past
|
// within SWRP_TIMEOUT_MS. A request that keeps discriminator busy past
|
||||||
// SWRP_TIMEOUT_MS, like any request while servewaf is overloaded, gets
|
// SWRP_TIMEOUT_MS, like any request while discriminator is overloaded, gets
|
||||||
// SWRP_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
|
// SWRP_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
|
||||||
// whenever servewaf 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.
|
// https://git.eeqj.de/sneak/smallwebwaf/issues/182.
|
||||||
rq.h.processLog.Warn("servewaf gave no answer", "error", err.Error())
|
rq.h.processLog.Warn("discriminator gave no answer", "error", err.Error())
|
||||||
|
|
||||||
rq.disposition = rq.h.config.DefaultDisposition
|
rq.disposition = rq.h.config.DefaultDisposition
|
||||||
rq.byDefault = true
|
rq.byDefault = true
|
||||||
|
|
||||||
if rq.disposition == servewaf.DispositionAllow {
|
if rq.disposition == discriminator.DispositionAllow {
|
||||||
rq.passToApp(ctx)
|
rq.passToApp(ctx)
|
||||||
} else {
|
} else {
|
||||||
rq.answer(http.StatusServiceUnavailable)
|
rq.answer(http.StatusServiceUnavailable)
|
||||||
@@ -82,10 +83,10 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ask sends servewaf 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
|
// whole within SWRP_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
|
||||||
// away.
|
// away.
|
||||||
func (rq *request) ask(ctx context.Context) (*servewafAnswer, error) {
|
func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
|
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ func (rq *request) ask(ctx context.Context) (*servewafAnswer, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := rq.h.serveWAFTransport.RoundTrip(question)
|
res, err := rq.h.discriminatorTransport.RoundTrip(question)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("send the question: %w", err)
|
return nil, fmt.Errorf("send the question: %w", err)
|
||||||
}
|
}
|
||||||
@@ -119,17 +120,17 @@ func (rq *request) ask(ctx context.Context) (*servewafAnswer, error) {
|
|||||||
return nil, fmt.Errorf("%w: its body is over 1 MiB", errCannotPassOn)
|
return nil, fmt.Errorf("%w: its body is over 1 MiB", errCannotPassOn)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &servewafAnswer{res.StatusCode, res.Header, body}, nil
|
return &discriminatorAnswer{res.StatusCode, res.Header, body}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// canBeAsked reports whether Go's client can send servewaf 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:
|
// it. It cannot send these as they came:
|
||||||
// - a target that does not start with /, or that starts with //;
|
// - a target that does not start with /, or that starts with //;
|
||||||
// - an empty Host, for which it sends servewaf's own address;
|
// - an empty Host, for which it sends discriminator's own address;
|
||||||
// - a Host holding an IPv6 zone, which it removes;
|
// - a Host holding an IPv6 zone, which it removes;
|
||||||
// - more than one User-Agent, of which it sends the first;
|
// - 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
|
// - an empty User-Agent, which it leaves out, the one case that still goes to
|
||||||
// servewaf.
|
// discriminator.
|
||||||
func canBeAsked(r *http.Request) bool {
|
func canBeAsked(r *http.Request) bool {
|
||||||
target, host := r.RequestURI, r.Host
|
target, host := r.RequestURI, r.Host
|
||||||
hostHasZone := strings.HasPrefix(host, "[") && strings.Contains(host, "%")
|
hostHasZone := strings.HasPrefix(host, "[") && strings.Contains(host, "%")
|
||||||
@@ -169,13 +170,13 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
|
|||||||
question.TransferEncoding = in.TransferEncoding
|
question.TransferEncoding = in.TransferEncoding
|
||||||
|
|
||||||
question.Header = in.Header.Clone()
|
question.Header = in.Header.Clone()
|
||||||
question.Header.Set(servewaf.ClientHeader, rq.client.String())
|
question.Header.Set(discriminator.ClientHeader, rq.client.String())
|
||||||
question.Header.Set(servewaf.PeerHeader, rq.peer.String())
|
question.Header.Set(discriminator.PeerHeader, rq.peer.String())
|
||||||
question.Header.Del(servewaf.ContentLengthHeader)
|
question.Header.Del(discriminator.ContentLengthHeader)
|
||||||
|
|
||||||
announced := in.Header.Get("Content-Length")
|
announced := in.Header.Get("Content-Length")
|
||||||
if announced != "" {
|
if announced != "" {
|
||||||
question.Header.Set(servewaf.ContentLengthHeader, announced)
|
question.Header.Set(discriminator.ContentLengthHeader, announced)
|
||||||
}
|
}
|
||||||
|
|
||||||
question.Header.Set(requestIDHeader, rq.id)
|
question.Header.Set(requestIDHeader, rq.id)
|
||||||
@@ -190,13 +191,13 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
|
|||||||
return question, nil
|
return question, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// answerWith gives the visitor servewaf's answer, less its disposition.
|
// answerWith gives the visitor discriminator's answer, less its disposition.
|
||||||
func (rq *request) answerWith(answer *servewafAnswer) {
|
func (rq *request) answerWith(answer *discriminatorAnswer) {
|
||||||
rq.prepareOwnAnswer(rq.requestDeadline())
|
rq.prepareOwnAnswer(rq.requestDeadline())
|
||||||
|
|
||||||
header := rq.out.Header()
|
header := rq.out.Header()
|
||||||
maps.Copy(header, answer.header)
|
maps.Copy(header, answer.header)
|
||||||
header.Del(servewaf.DispositionHeader)
|
header.Del(discriminator.DispositionHeader)
|
||||||
|
|
||||||
// Go's server adds a Content-Type it guesses from the body to a response whose
|
// 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.
|
// headers have no Content-Type key. The key with no value sends none.
|
||||||
@@ -207,10 +208,10 @@ func (rq *request) answerWith(answer *servewafAnswer) {
|
|||||||
rq.send(answer.status, answer.body)
|
rq.send(answer.status, answer.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendReport sends servewaf 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
|
// visitor does not wait for it. The report goes on after ctx, the request's own
|
||||||
// context, has ended.
|
// context, has ended.
|
||||||
func (h *handler) sendReport(ctx context.Context, report *servewaf.Report) {
|
func (h *handler) sendReport(ctx context.Context, report *discriminator.Report) {
|
||||||
h.reportsMu.Lock()
|
h.reportsMu.Lock()
|
||||||
defer h.reportsMu.Unlock()
|
defer h.reportsMu.Unlock()
|
||||||
|
|
||||||
@@ -226,9 +227,9 @@ func (h *handler) sendReport(ctx context.Context, report *servewaf.Report) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// postReport posts report to servewaf, and waits for its answer for up to
|
// postReport posts report to discriminator, and waits for its answer for up to
|
||||||
// SWRP_TIMEOUT_MS.
|
// SWRP_TIMEOUT_MS.
|
||||||
func (h *handler) postReport(ctx context.Context, report *servewaf.Report) error {
|
func (h *handler) postReport(ctx context.Context, report *discriminator.Report) error {
|
||||||
body, err := json.Marshal(report)
|
body, err := json.Marshal(report)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encode the report: %w", err)
|
return fmt.Errorf("encode the report: %w", err)
|
||||||
@@ -245,7 +246,7 @@ func (h *handler) postReport(ctx context.Context, report *servewaf.Report) error
|
|||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
res, err := h.serveWAFTransport.RoundTrip(req)
|
res, err := h.discriminatorTransport.RoundTrip(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("send the report: %w", err)
|
return fmt.Errorf("send the report: %w", err)
|
||||||
}
|
}
|
||||||
@@ -16,9 +16,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// post is the start of a POST whose headers end with what follows it.
|
// post is the start of a POST whose headers end with what follows it.
|
||||||
@@ -58,7 +58,7 @@ func TestServerHasTheFixedLimits(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) {
|
func TestABodyAnnouncedOverItsLimitIsRefusedOnceDiscriminatorAllowsIt(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -71,7 +71,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
t.Error("the app was asked")
|
t.Error("the app was asked")
|
||||||
})
|
})
|
||||||
@@ -100,7 +100,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) {
|
|||||||
func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
|
func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, deny)
|
standIn := startDiscriminator(t, deny)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
maxRequestSize: "1K",
|
maxRequestSize: "1K",
|
||||||
@@ -109,7 +109,7 @@ func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
|
|||||||
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
|
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
|
||||||
bytes.NewReader(make([]byte, 1025))))
|
bytes.NewReader(make([]byte, 1025))))
|
||||||
if got.status != http.StatusForbidden {
|
if got.status != http.StatusForbidden {
|
||||||
t.Errorf("answered %+v, want servewaf's 403", got)
|
t.Errorf("answered %+v, want discriminator's 403", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
|
|||||||
|
|
||||||
var asked atomic.Bool
|
var asked atomic.Bool
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
asked.Store(true)
|
asked.Store(true)
|
||||||
greet(w, r)
|
greet(w, r)
|
||||||
@@ -165,7 +165,7 @@ func TestAResponseOverItsLimit(t *testing.T) {
|
|||||||
t.Run("announced, is refused", func(t *testing.T) {
|
t.Run("announced, is refused", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set(contentLengthHeader, strconv.Itoa(len(large)))
|
w.Header().Set(contentLengthHeader, strconv.Itoa(len(large)))
|
||||||
_, _ = io.WriteString(w, large)
|
_, _ = io.WriteString(w, large)
|
||||||
@@ -187,7 +187,7 @@ func TestAResponseOverItsLimit(t *testing.T) {
|
|||||||
t.Run("not announced, is cut off", func(t *testing.T) {
|
t.Run("not announced, is cut off", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
_, _ = io.WriteString(w, large[:1])
|
_, _ = io.WriteString(w, large[:1])
|
||||||
_ = http.NewResponseController(w).Flush()
|
_ = http.NewResponseController(w).Flush()
|
||||||
@@ -217,7 +217,7 @@ func TestAHeadWhoseAnswerAnnouncesMoreThanTheResponseLimitIsPassedOn(t *testing.
|
|||||||
|
|
||||||
const announced = "2048"
|
const announced = "2048"
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set(contentLengthHeader, announced)
|
w.Header().Set(contentLengthHeader, announced)
|
||||||
})
|
})
|
||||||
@@ -240,7 +240,7 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) {
|
|||||||
t.Run(setting, func(t *testing.T) {
|
t.Run(setting, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
setting: "off",
|
setting: "off",
|
||||||
@@ -251,7 +251,7 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) {
|
|||||||
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
|
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
|
||||||
strings.NewReader("a body"))))
|
strings.NewReader("a body"))))
|
||||||
|
|
||||||
if got := get(t, addr, servewaf.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)
|
t.Errorf("answered %+v, want rproxy's own 200", got)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -270,7 +270,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
maxRequestTime: "500",
|
maxRequestTime: "500",
|
||||||
@@ -289,7 +289,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(standIn.questions) != 0 {
|
if len(standIn.questions) != 0 {
|
||||||
t.Error("servewaf was asked")
|
t.Error("discriminator was asked")
|
||||||
}
|
}
|
||||||
|
|
||||||
standIn.wantNoReport(t)
|
standIn.wantNoReport(t)
|
||||||
@@ -300,7 +300,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
|||||||
func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
|
func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
@@ -314,7 +314,7 @@ func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(standIn.questions) != 0 {
|
if len(standIn.questions) != 0 {
|
||||||
t.Error("servewaf was asked")
|
t.Error("discriminator was asked")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,7 +334,7 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
@@ -365,7 +365,7 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(standIn.questions) != 0 {
|
if len(standIn.questions) != 0 {
|
||||||
t.Error("servewaf was asked")
|
t.Error("discriminator was asked")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -404,7 +404,7 @@ func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) {
|
|||||||
announced = 100 * questionBodyMaxBytes
|
announced = 100 * questionBodyMaxBytes
|
||||||
)
|
)
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
})
|
})
|
||||||
@@ -448,13 +448,13 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
|
|||||||
name, target string
|
name, target string
|
||||||
status int
|
status int
|
||||||
}{
|
}{
|
||||||
{"servewaf's deny", "/", http.StatusForbidden},
|
{"discriminator's deny", "/", http.StatusForbidden},
|
||||||
{"rproxy's own 400", "x:admin", http.StatusBadRequest},
|
{"rproxy's own 400", "x:admin", http.StatusBadRequest},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, deny)
|
standIn := startDiscriminator(t, deny)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
_, _, server := startServer(t, standIn.URL, app.URL, map[string]string{
|
_, _, server := startServer(t, standIn.URL, app.URL, map[string]string{
|
||||||
maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
|
maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
|
||||||
@@ -484,7 +484,7 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
|
|||||||
func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
|
func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
maxRequestTime: "3000",
|
maxRequestTime: "3000",
|
||||||
@@ -553,7 +553,7 @@ func TestAnAppTooSlowToTakeTheRequestIsAnswered504(t *testing.T) {
|
|||||||
writeSize = 32 << 10
|
writeSize = 32 << 10
|
||||||
)
|
)
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
addr, _ := startRProxy(t, standIn.URL, startAppThatTakesNothing(t),
|
addr, _ := startRProxy(t, standIn.URL, startAppThatTakesNothing(t),
|
||||||
map[string]string{maxRequestTime: "3000"})
|
map[string]string{maxRequestTime: "3000"})
|
||||||
|
|
||||||
@@ -580,7 +580,7 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
<-release
|
<-release
|
||||||
})
|
})
|
||||||
@@ -607,7 +607,7 @@ func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
|
|||||||
const start = "the start"
|
const start = "the start"
|
||||||
|
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
_, _ = io.WriteString(w, start)
|
_, _ = io.WriteString(w, start)
|
||||||
_ = http.NewResponseController(w).Flush()
|
_ = http.NewResponseController(w).Flush()
|
||||||
@@ -656,7 +656,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
|
|||||||
writeSize = 32 << 10
|
writeSize = 32 << 10
|
||||||
)
|
)
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
part := make([]byte, writeSize)
|
part := make([]byte, writeSize)
|
||||||
|
|
||||||
@@ -693,7 +693,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
|
|||||||
func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
|
func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
||||||
|
|
||||||
got := get(t, addr, "/")
|
got := get(t, addr, "/")
|
||||||
@@ -709,7 +709,7 @@ func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
|
|||||||
func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) {
|
func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
_, _ = io.WriteString(w, greeting)
|
_, _ = io.WriteString(w, greeting)
|
||||||
})
|
})
|
||||||
@@ -732,7 +732,7 @@ func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
working := make(chan struct{})
|
working := make(chan struct{})
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||||
close(working)
|
close(working)
|
||||||
<-r.Context().Done()
|
<-r.Context().Done()
|
||||||
@@ -773,7 +773,7 @@ func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing
|
|||||||
|
|
||||||
const answer = "the end of a long answer"
|
const answer = "the end of a long answer"
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = io.Copy(io.Discard, r.Body)
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
_ = http.NewResponseController(w).Flush()
|
_ = http.NewResponseController(w).Flush()
|
||||||
@@ -802,14 +802,14 @@ func TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline(t *testing
|
|||||||
{"a deny", post, http.StatusForbidden},
|
{"a deny", post, http.StatusForbidden},
|
||||||
{
|
{
|
||||||
"the health check",
|
"the health check",
|
||||||
"GET " + servewaf.HealthPath + " HTTP/1.1\r\nHost: site.example\r\n",
|
"GET " + discriminator.HealthPath + " HTTP/1.1\r\nHost: site.example\r\n",
|
||||||
http.StatusOK,
|
http.StatusOK,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, deny)
|
standIn := startDiscriminator(t, deny)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
// The time limit is long enough for a loaded host to read the first part of
|
// The time limit is long enough for a loaded host to read the first part of
|
||||||
// the body before it runs out.
|
// the body before it runs out.
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
)
|
)
|
||||||
|
|
||||||
// questionBodyMaxBytes is the most of a body a question carries.
|
// questionBodyMaxBytes is the most of a body a question carries.
|
||||||
const questionBodyMaxBytes = servewaf.QuestionBodyMaxBytes
|
const questionBodyMaxBytes = discriminator.QuestionBodyMaxBytes
|
||||||
|
|
||||||
// chunked is the encoding of a body whose length the visitor did not announce.
|
// chunked is the encoding of a body whose length the visitor did not announce.
|
||||||
const chunked = "chunked"
|
const chunked = "chunked"
|
||||||
@@ -29,7 +29,7 @@ const pathGoReencodes = "/some%2Fpath/with%20space|pipe"
|
|||||||
func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
|
func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
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(forwardedForHeader, "198.51.100.1")
|
||||||
req.Header.Set(forwardedProtoHeader, "gopher")
|
req.Header.Set(forwardedProtoHeader, "gopher")
|
||||||
req.Header.Set(requestIDHeader, "the-visitor's")
|
req.Header.Set(requestIDHeader, "the-visitor's")
|
||||||
req.Header.Set(servewaf.ClientHeader, "198.51.100.2")
|
req.Header.Set(discriminator.ClientHeader, "198.51.100.2")
|
||||||
req.Header.Set(servewaf.PeerHeader, "198.51.100.3")
|
req.Header.Set(discriminator.PeerHeader, "198.51.100.3")
|
||||||
req.Header.Set(servewaf.ContentLengthHeader, "12345")
|
req.Header.Set(discriminator.ContentLengthHeader, "12345")
|
||||||
// Go's client then sends no User-Agent, so that one in the question would be
|
// Go's client then sends no User-Agent, so that one in the question would be
|
||||||
// Go's own.
|
// Go's own.
|
||||||
req.Header["User-Agent"] = nil
|
req.Header["User-Agent"] = nil
|
||||||
@@ -68,8 +68,8 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
|
|||||||
want.Set(forwardedForHeader, "198.51.100.1")
|
want.Set(forwardedForHeader, "198.51.100.1")
|
||||||
want.Set(forwardedProtoHeader, "http")
|
want.Set(forwardedProtoHeader, "http")
|
||||||
want.Set(requestIDHeader, asked.header.Get(requestIDHeader))
|
want.Set(requestIDHeader, asked.header.Get(requestIDHeader))
|
||||||
want.Set(servewaf.ClientHeader, localhost)
|
want.Set(discriminator.ClientHeader, localhost)
|
||||||
want.Set(servewaf.PeerHeader, localhost)
|
want.Set(discriminator.PeerHeader, localhost)
|
||||||
|
|
||||||
if !maps.EqualFunc(asked.header, want, slices.Equal) {
|
if !maps.EqualFunc(asked.header, want, slices.Equal) {
|
||||||
t.Errorf("the question's headers are %v, want %v", asked.header, want)
|
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.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
forwarded := make(chan http.Header, 1)
|
forwarded := make(chan http.Header, 1)
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
forwarded <- r.Header.Clone()
|
forwarded <- r.Header.Clone()
|
||||||
@@ -110,8 +110,8 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
|
|||||||
wantGreeting(t, visit(t, req))
|
wantGreeting(t, visit(t, req))
|
||||||
|
|
||||||
asked := standIn.question(t)
|
asked := standIn.question(t)
|
||||||
if asked.header.Get(servewaf.ClientHeader) != tc.client ||
|
if asked.header.Get(discriminator.ClientHeader) != tc.client ||
|
||||||
asked.header.Get(servewaf.PeerHeader) != localhost ||
|
asked.header.Get(discriminator.PeerHeader) != localhost ||
|
||||||
asked.header.Get(forwardedForHeader) != visitor ||
|
asked.header.Get(forwardedForHeader) != visitor ||
|
||||||
asked.header.Get(forwardedProtoHeader) != tc.proto {
|
asked.header.Get(forwardedProtoHeader) != tc.proto {
|
||||||
t.Errorf("the question's headers are %v", asked.header)
|
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.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
trustedProxies: localhost + "/32,10.0.0.0/8",
|
trustedProxies: localhost + "/32,10.0.0.0/8",
|
||||||
@@ -167,7 +167,8 @@ func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) {
|
|||||||
req.Header[forwardedForHeader] = tc.header
|
req.Header[forwardedForHeader] = tc.header
|
||||||
wantGreeting(t, visit(t, req))
|
wantGreeting(t, visit(t, req))
|
||||||
|
|
||||||
if got := standIn.question(t).header.Get(servewaf.ClientHeader); got != tc.client {
|
if got := standIn.question(t).header.Get(discriminator.ClientHeader); got !=
|
||||||
|
tc.client {
|
||||||
t.Errorf("the question names %s, want %s", got, tc.client)
|
t.Errorf("the question names %s, want %s", got, tc.client)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -182,7 +183,7 @@ func randomBytes(n int) []byte {
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) {
|
func TestFirst64KiBReachDiscriminatorAndTheWholeBodyTheApp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, size := range []int{
|
for _, size := range []int{
|
||||||
@@ -197,7 +198,7 @@ func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
body := randomBytes(size)
|
body := randomBytes(size)
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
received := make(chan []byte, 1)
|
received := make(chan []byte, 1)
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
whole, _ := io.ReadAll(r.Body)
|
whole, _ := io.ReadAll(r.Body)
|
||||||
@@ -243,7 +244,7 @@ func wantQuestionBody(
|
|||||||
len(want))
|
len(want))
|
||||||
}
|
}
|
||||||
|
|
||||||
values := asked.header.Values(servewaf.ContentLengthHeader)
|
values := asked.header.Values(discriminator.ContentLengthHeader)
|
||||||
|
|
||||||
if announced {
|
if announced {
|
||||||
if !slices.Equal(values, []string{strconv.Itoa(size)}) ||
|
if !slices.Equal(values, []string{strconv.Itoa(size)}) ||
|
||||||
@@ -264,7 +265,7 @@ func wantQuestionBody(
|
|||||||
func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
|
func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, deny)
|
standIn := startDiscriminator(t, deny)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
@@ -275,7 +276,7 @@ func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
|
|||||||
strings.Repeat("x", questionBodyMaxBytes))
|
strings.Repeat("x", questionBodyMaxBytes))
|
||||||
|
|
||||||
if status := readStatus(t, conn); status != http.StatusForbidden {
|
if status := readStatus(t, conn); status != http.StatusForbidden {
|
||||||
t.Errorf("status %d, want servewaf's 403", status)
|
t.Errorf("status %d, want discriminator's 403", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if report := standIn.report(t); report.RequestBytes != questionBodyMaxBytes {
|
if report := standIn.report(t); report.RequestBytes != questionBodyMaxBytes {
|
||||||
@@ -306,14 +307,14 @@ func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) {
|
|||||||
t.Run(method+" "+tc.name, func(t *testing.T) {
|
t.Run(method+" "+tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, deny)
|
standIn := startDiscriminator(t, deny)
|
||||||
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
||||||
|
|
||||||
conn := dial(t, addr)
|
conn := dial(t, addr)
|
||||||
send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing)
|
send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing)
|
||||||
|
|
||||||
if status := readStatus(t, conn); status != http.StatusForbidden {
|
if status := readStatus(t, conn); status != http.StatusForbidden {
|
||||||
t.Errorf("status %d, want servewaf's 403", status)
|
t.Errorf("status %d, want discriminator's 403", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
asked := standIn.question(t)
|
asked := standIn.question(t)
|
||||||
@@ -334,7 +335,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
|
|||||||
t.Run(method, func(t *testing.T) {
|
t.Run(method, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
t.Error("the app was asked")
|
t.Error("the app was asked")
|
||||||
})
|
})
|
||||||
@@ -353,7 +354,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(standIn.questions) != 0 {
|
if len(standIn.questions) != 0 {
|
||||||
t.Error("servewaf was asked")
|
t.Error("discriminator was asked")
|
||||||
}
|
}
|
||||||
|
|
||||||
standIn.wantNoReport(t)
|
standIn.wantNoReport(t)
|
||||||
@@ -361,7 +362,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) {
|
func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, method := range []string{
|
for _, method := range []string{
|
||||||
@@ -372,7 +373,7 @@ func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
reached := make(chan string, 1)
|
reached := make(chan string, 1)
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||||
reached <- r.Method
|
reached <- r.Method
|
||||||
})
|
})
|
||||||
@@ -384,7 +385,7 @@ func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if asked := standIn.question(t); asked.method != method {
|
if asked := standIn.question(t); asked.method != method {
|
||||||
t.Errorf("servewaf was asked about %s", asked.method)
|
t.Errorf("discriminator was asked about %s", asked.method)
|
||||||
}
|
}
|
||||||
|
|
||||||
if atApp := <-reached; atApp != method {
|
if atApp := <-reached; atApp != method {
|
||||||
@@ -394,7 +395,7 @@ func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) {
|
func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const host = "Host: site.example\r\n\r\n"
|
const host = "Host: site.example\r\n\r\n"
|
||||||
@@ -416,7 +417,7 @@ func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) {
|
|||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
@@ -434,7 +435,7 @@ func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(standIn.questions) != 0 {
|
if len(standIn.questions) != 0 {
|
||||||
t.Error("servewaf was asked")
|
t.Error("discriminator was asked")
|
||||||
}
|
}
|
||||||
|
|
||||||
standIn.wantNoReport(t)
|
standIn.wantNoReport(t)
|
||||||
@@ -442,12 +443,12 @@ func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAHostWithAPercentSignButNoBracketGoesToServeWAF(t *testing.T) {
|
func TestAHostWithAPercentSignButNoBracketGoesToDiscriminator(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const host = "a%b"
|
const host = "a%b"
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
@@ -468,7 +469,7 @@ func TestATrustedPeerWithNoForwardedProtoIsAskedAboutAsHTTP(t *testing.T) {
|
|||||||
|
|
||||||
const want = "http"
|
const want = "http"
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
trustedProxies: localhost + "/32",
|
trustedProxies: localhost + "/32",
|
||||||
@@ -497,7 +498,7 @@ func TestAQuestionNamesNoZone(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, deny)
|
standIn := startDiscriminator(t, deny)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
_, out, server := startServer(t, standIn.URL, app.URL, map[string]string{
|
_, out, server := startServer(t, standIn.URL, app.URL, map[string]string{
|
||||||
trustedProxies: localhost + "/32",
|
trustedProxies: localhost + "/32",
|
||||||
@@ -510,8 +511,8 @@ func TestAQuestionNamesNoZone(t *testing.T) {
|
|||||||
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
asked := standIn.question(t)
|
asked := standIn.question(t)
|
||||||
if asked.header.Get(servewaf.ClientHeader) != tc.client ||
|
if asked.header.Get(discriminator.ClientHeader) != tc.client ||
|
||||||
asked.header.Get(servewaf.PeerHeader) != tc.peer {
|
asked.header.Get(discriminator.PeerHeader) != tc.peer {
|
||||||
t.Errorf("the question's headers are %v", asked.header)
|
t.Errorf("the question's headers are %v", asked.header)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-11
@@ -16,16 +16,16 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// flushAfterEachWrite has ReverseProxy pass on each part of the app's answer as
|
// flushAfterEachWrite has ReverseProxy pass on each part of the app's answer as
|
||||||
// soon as it arrives.
|
// soon as it arrives.
|
||||||
const flushAfterEachWrite time.Duration = -1
|
const flushAfterEachWrite time.Duration = -1
|
||||||
|
|
||||||
// reportStatusMax is the highest status servewaf takes in a report. An app can
|
// reportStatusMax is the highest status discriminator takes in a report. An app can
|
||||||
// send a higher one, up to 999, and the visitor gets that as it is.
|
// send a higher one, up to 999, and the visitor gets that as it is.
|
||||||
const reportStatusMax = 599
|
const reportStatusMax = 599
|
||||||
|
|
||||||
@@ -49,14 +49,14 @@ type request struct {
|
|||||||
peerTrusted bool
|
peerTrusted bool
|
||||||
start time.Time
|
start time.Time
|
||||||
|
|
||||||
// disposition is what is done with the request: servewaf's valid disposition,
|
// disposition is what is done with the request: discriminator's valid disposition,
|
||||||
// or SWRP_DEFAULT_DISPOSITION. It is "" when neither gave one.
|
// or SWRP_DEFAULT_DISPOSITION. It is "" when neither gave one.
|
||||||
disposition string
|
disposition string
|
||||||
// answered is true when servewaf gave disposition, which makes a report due,
|
// answered is true when discriminator gave disposition, which makes a report due,
|
||||||
// and byDefault when SWRP_DEFAULT_DISPOSITION did.
|
// and byDefault when SWRP_DEFAULT_DISPOSITION did.
|
||||||
answered bool
|
answered bool
|
||||||
byDefault bool
|
byDefault bool
|
||||||
// asn and country are servewaf's X-Client-ASN and X-Client-Country, for the
|
// asn and country are discriminator's X-Client-ASN and X-Client-Country, for the
|
||||||
// app.
|
// app.
|
||||||
asn, country string
|
asn, country string
|
||||||
|
|
||||||
@@ -122,14 +122,14 @@ func (rq *request) answerHealth() {
|
|||||||
// visitor too slow to send it is answered 408. For a malformed chunk, or a
|
// visitor too slow to send it is answered 408. For a malformed chunk, or a
|
||||||
// visitor that went away or stopped its sending side, the connection is closed
|
// visitor that went away or stopped its sending side, the connection is closed
|
||||||
// without an answer. A body over SWRP_MAX_REQUEST_SIZE goes on with the part
|
// without an answer. A body over SWRP_MAX_REQUEST_SIZE goes on with the part
|
||||||
// read, so that servewaf is asked first; passToApp then refuses it.
|
// read, so that discriminator is asked first; passToApp then refuses it.
|
||||||
func (rq *request) readFirstPartOfBody() bool {
|
func (rq *request) readFirstPartOfBody() bool {
|
||||||
if rq.body == nil {
|
if rq.body == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = rq.rc.SetReadDeadline(rq.requestDeadline())
|
_ = rq.rc.SetReadDeadline(rq.requestDeadline())
|
||||||
held, err := io.ReadAll(io.LimitReader(rq.body, servewaf.QuestionBodyMaxBytes))
|
held, err := io.ReadAll(io.LimitReader(rq.body, discriminator.QuestionBodyMaxBytes))
|
||||||
// From here on, the request timer ends a read of the rest of the body, so that
|
// From here on, the request timer ends a read of the rest of the body, so that
|
||||||
// the answer can name the side rproxy was waiting on.
|
// the answer can name the side rproxy was waiting on.
|
||||||
_ = rq.rc.SetReadDeadline(time.Time{})
|
_ = rq.rc.SetReadDeadline(time.Time{})
|
||||||
@@ -192,7 +192,7 @@ func (rq *request) forward(ctx context.Context) {
|
|||||||
// rewrite makes the request the app receives: the visitor's request, unchanged,
|
// rewrite makes the request the app receives: the visitor's request, unchanged,
|
||||||
// sent to SWRP_UPSTREAM_URL, with the forwarded headers and the request's id set,
|
// sent to SWRP_UPSTREAM_URL, with the forwarded headers and the request's id set,
|
||||||
// without any X-Client-ASN or X-Client-Country the visitor sent, and with
|
// without any X-Client-ASN or X-Client-Country the visitor sent, and with
|
||||||
// servewaf's.
|
// discriminator's.
|
||||||
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
||||||
upstream := rq.h.config.UpstreamURL
|
upstream := rq.h.config.UpstreamURL
|
||||||
pr.Out.URL.Scheme = upstream.Scheme
|
pr.Out.URL.Scheme = upstream.Scheme
|
||||||
@@ -312,7 +312,7 @@ func (rq *request) answer(status int) {
|
|||||||
// handler has returned, after finish has written the log line and sent the report.
|
// handler has returned, after finish has written the log line and sent the report.
|
||||||
// Without Content-Length, the flush would send the body in chunks, the last of
|
// Without Content-Length, the flush would send the body in chunks, the last of
|
||||||
// which would still wait for the handler. A HEAD's answer has no body, so any
|
// which would still wait for the handler. A HEAD's answer has no body, so any
|
||||||
// Content-Length it has stays as servewaf gave it.
|
// Content-Length it has stays as discriminator gave it.
|
||||||
func (rq *request) send(status int, body []byte) {
|
func (rq *request) send(status int, body []byte) {
|
||||||
if rq.in.Method != http.MethodHead {
|
if rq.in.Method != http.MethodHead {
|
||||||
rq.out.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
rq.out.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||||
@@ -347,7 +347,7 @@ func (rq *request) refuse(status int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// finish ends the request's time limits, writes its log line and, if servewaf
|
// finish ends the request's time limits, writes its log line and, if discriminator
|
||||||
// answered the question, sends the report. ctx is the request's own context.
|
// answered the question, sends the report. ctx is the request's own context.
|
||||||
func (rq *request) finish(ctx context.Context) {
|
func (rq *request) finish(ctx context.Context) {
|
||||||
rq.stopTimers()
|
rq.stopTimers()
|
||||||
@@ -384,7 +384,7 @@ func (rq *request) finish(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
report := &servewaf.Report{
|
report := &discriminator.Report{
|
||||||
RequestID: rq.id,
|
RequestID: rq.id,
|
||||||
Status: min(rq.out.status, reportStatusMax),
|
Status: min(rq.out.status, reportStatusMax),
|
||||||
RequestBytes: requestBytes,
|
RequestBytes: requestBytes,
|
||||||
|
|||||||
+27
-27
@@ -1,10 +1,10 @@
|
|||||||
// Package rproxy is the reverse proxy in front of one app that asks servewaf
|
// Package rproxy is the reverse proxy in front of one app that asks discriminator
|
||||||
// about each request. It passes a request servewaf allows to the app and the
|
// about each request. It passes a request discriminator allows to the app and the
|
||||||
// app's answer back, within the size and time limits. Any other answer from
|
// app's answer back, within the size and time limits. Any other answer from
|
||||||
// servewaf goes to the visitor as servewaf gave it. SWRP_DEFAULT_DISPOSITION
|
// discriminator goes to the visitor as discriminator gave it. SWRP_DEFAULT_DISPOSITION
|
||||||
// decides only when servewaf cannot be connected to, does not answer at all, or
|
// decides only when discriminator cannot be connected to, does not answer at all, or
|
||||||
// does not answer within SWRP_TIMEOUT_MS. rproxy itself refuses a request with a
|
// does not answer within SWRP_TIMEOUT_MS. rproxy itself refuses a request with a
|
||||||
// method it does not pass on, or one it cannot put to servewaf as the visitor
|
// method it does not pass on, or one it cannot put to discriminator as the visitor
|
||||||
// sent it. It keeps nothing between requests and never touches disk. "rproxy" in
|
// sent it. It keeps nothing between requests and never touches disk. "rproxy" in
|
||||||
// SPEC.md says what it does.
|
// SPEC.md says what it does.
|
||||||
package rproxy
|
package rproxy
|
||||||
@@ -22,10 +22,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
)
|
)
|
||||||
|
|
||||||
// How rproxy keeps connections to the app and to servewaf open between
|
// How rproxy keeps connections to the app and to discriminator open between
|
||||||
// requests.
|
// requests.
|
||||||
const (
|
const (
|
||||||
idleConns = 100
|
idleConns = 100
|
||||||
@@ -45,9 +45,9 @@ const (
|
|||||||
idleTimeout = 120 * time.Second
|
idleTimeout = 120 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminPrefix starts the path of every request for smallwebwaf itself, which
|
// adminPrefix starts the path of every request for sww itself, which
|
||||||
// never reaches the app.
|
// never reaches the app.
|
||||||
const adminPrefix = "/_smallwebwaf/"
|
const adminPrefix = "/_sww/"
|
||||||
|
|
||||||
// allowedMethods lists the methods rproxy passes on, as an Allow header gives
|
// allowedMethods lists the methods rproxy passes on, as an Allow header gives
|
||||||
// them. It answers any other method 405 itself.
|
// them. It answers any other method 405 itself.
|
||||||
@@ -62,14 +62,14 @@ type Params struct {
|
|||||||
ProcessLog *slog.Logger
|
ProcessLog *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the server `smallwebwaf rproxy` runs.
|
// Server is the server `sww rproxy` runs.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
*http.Server
|
*http.Server
|
||||||
|
|
||||||
h *handler
|
h *handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns the server `smallwebwaf rproxy` runs. Go's server itself refuses
|
// New returns the server `sww rproxy` runs. Go's server itself refuses
|
||||||
// a request line and headers over requestHeaderMaxBytes, with 431, closes a
|
// a request line and headers over requestHeaderMaxBytes, with 431, closes a
|
||||||
// connection idle for idleTimeout, and applies SWRP_MAX_REQUEST_TIME_MS while
|
// connection idle for idleTimeout, and applies SWRP_MAX_REQUEST_TIME_MS while
|
||||||
// the headers arrive. The handler applies the time and size limits from then
|
// the headers arrive. The handler applies the time and size limits from then
|
||||||
@@ -83,9 +83,9 @@ func New(params Params) *Server {
|
|||||||
processLog: params.ProcessLog,
|
processLog: params.ProcessLog,
|
||||||
errorLog: errorLog,
|
errorLog: errorLog,
|
||||||
transport: newTransport(),
|
transport: newTransport(),
|
||||||
serveWAFTransport: newServeWAFTransport(),
|
discriminatorTransport: newDiscriminatorTransport(),
|
||||||
reportURL: (&url.URL{
|
reportURL: (&url.URL{
|
||||||
Scheme: backend.Scheme, Host: backend.Host, Path: servewaf.ReportPath,
|
Scheme: backend.Scheme, Host: backend.Host, Path: discriminator.ReportPath,
|
||||||
}).String(),
|
}).String(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,10 +120,10 @@ type handler struct {
|
|||||||
requestLog io.Writer
|
requestLog io.Writer
|
||||||
processLog *slog.Logger
|
processLog *slog.Logger
|
||||||
errorLog *log.Logger
|
errorLog *log.Logger
|
||||||
// transport carries requests to the app, and serveWAFTransport questions and
|
// transport carries requests to the app, and discriminatorTransport questions and
|
||||||
// reports to servewaf.
|
// reports to discriminator.
|
||||||
transport http.RoundTripper
|
transport http.RoundTripper
|
||||||
serveWAFTransport http.RoundTripper
|
discriminatorTransport http.RoundTripper
|
||||||
// reportURL is where the reports go.
|
// reportURL is where the reports go.
|
||||||
reportURL string
|
reportURL string
|
||||||
// reports waits for the reports being sent. reportsMu guards starting one
|
// reports waits for the reports being sent. reportsMu guards starting one
|
||||||
@@ -145,11 +145,11 @@ func newTransport() *http.Transport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newServeWAFTransport returns what carries questions and reports to servewaf.
|
// newDiscriminatorTransport returns what carries questions and reports to
|
||||||
// It speaks HTTP/1.1 only. Go's HTTP/2 client refuses to send a visitor's Upgrade
|
// discriminator. It speaks HTTP/1.1 only. Go's HTTP/2 client refuses to send
|
||||||
// header, or a Connection header other than close or keep-alive, and that would
|
// a visitor's Upgrade header, or a Connection header other than close or
|
||||||
// read as servewaf giving no answer.
|
// keep-alive, and that would read as discriminator giving no answer.
|
||||||
func newServeWAFTransport() *http.Transport {
|
func newDiscriminatorTransport() *http.Transport {
|
||||||
transport := newTransport()
|
transport := newTransport()
|
||||||
transport.Protocols = new(http.Protocols)
|
transport.Protocols = new(http.Protocols)
|
||||||
transport.Protocols.SetHTTP1(true)
|
transport.Protocols.SetHTTP1(true)
|
||||||
@@ -158,15 +158,15 @@ func newServeWAFTransport() *http.Transport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ServeHTTP handles one request: it answers the health check itself, refuses a
|
// ServeHTTP handles one request: it answers the health check itself, refuses a
|
||||||
// request with a method it does not pass on or one it cannot put to servewaf as
|
// request with a method it does not pass on or one it cannot put to discriminator as
|
||||||
// the visitor sent it, and for any other request reads the first part of the
|
// the visitor sent it, and for any other request reads the first part of the
|
||||||
// body, asks servewaf, and does what the answer says. Once the answer has ended,
|
// body, asks discriminator, and does what the answer says. Once the answer has ended,
|
||||||
// it writes the request's log line and sends its report.
|
// it writes the request's log line and sends its report.
|
||||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
rq := h.newRequest(w, r)
|
rq := h.newRequest(w, r)
|
||||||
defer rq.finish(r.Context())
|
defer rq.finish(r.Context())
|
||||||
|
|
||||||
if r.Method == http.MethodGet && r.URL.Path == servewaf.HealthPath {
|
if r.Method == http.MethodGet && r.URL.Path == discriminator.HealthPath {
|
||||||
rq.answerHealth()
|
rq.answerHealth()
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -199,9 +199,9 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
rq.noteAnswer(answer)
|
rq.noteAnswer(answer)
|
||||||
|
|
||||||
switch rq.disposition {
|
switch rq.disposition {
|
||||||
case servewaf.DispositionAllow:
|
case discriminator.DispositionAllow:
|
||||||
rq.passToApp(r.Context())
|
rq.passToApp(r.Context())
|
||||||
case servewaf.DispositionClose:
|
case discriminator.DispositionClose:
|
||||||
// This panic has Go's server close the connection without an answer,
|
// This panic has Go's server close the connection without an answer,
|
||||||
// and log nothing; the deferred finish still runs.
|
// and log nothing; the deferred finish still runs.
|
||||||
panic(http.ErrAbortHandler)
|
panic(http.ErrAbortHandler)
|
||||||
@@ -211,7 +211,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// passToApp passes the request on to the app, unless a limit or its path keeps
|
// passToApp passes the request on to the app, unless a limit or its path keeps
|
||||||
// it from the app. servewaf answers a path under adminPrefix itself, so such a
|
// it from the app. discriminator answers a path under adminPrefix itself, so such a
|
||||||
// request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the
|
// request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the
|
||||||
// request's own context.
|
// request's own context.
|
||||||
func (rq *request) passToApp(ctx context.Context) {
|
func (rq *request) passToApp(ctx context.Context) {
|
||||||
|
|||||||
+110
-106
@@ -19,9 +19,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -54,27 +54,27 @@ const (
|
|||||||
|
|
||||||
// The headers the tests set and look at.
|
// The headers the tests set and look at.
|
||||||
const (
|
const (
|
||||||
dispositionHeader = servewaf.DispositionHeader
|
dispositionHeader = discriminator.DispositionHeader
|
||||||
forwardedForHeader = "X-Forwarded-For"
|
forwardedForHeader = "X-Forwarded-For"
|
||||||
forwardedProtoHeader = "X-Forwarded-Proto"
|
forwardedProtoHeader = "X-Forwarded-Proto"
|
||||||
requestIDHeader = "X-Request-ID"
|
requestIDHeader = "X-Request-ID"
|
||||||
asnHeader = "X-Client-Asn"
|
asnHeader = "X-Client-Asn"
|
||||||
countryHeader = "X-Client-Country"
|
countryHeader = "X-Client-Country"
|
||||||
contentLengthHeader = "Content-Length"
|
contentLengthHeader = "Content-Length"
|
||||||
// fromServeWAFHeader is a header the stand-in for servewaf adds to a deny.
|
// fromDiscriminatorHeader is a header the stand-in for discriminator adds to a deny.
|
||||||
fromServeWAFHeader = "X-From-Servewaf"
|
fromDiscriminatorHeader = "X-From-Discriminator"
|
||||||
// addedHeaderValue is the value of a header a test adds to an answer, to see it
|
// addedHeaderValue is the value of a header a test adds to an answer, to see it
|
||||||
// reach the visitor.
|
// reach the visitor.
|
||||||
addedHeaderValue = "yes"
|
addedHeaderValue = "yes"
|
||||||
)
|
)
|
||||||
|
|
||||||
// What the stand-in for servewaf answers.
|
// What the stand-in for discriminator answers.
|
||||||
const (
|
const (
|
||||||
dispositionAllow = servewaf.DispositionAllow
|
dispositionAllow = discriminator.DispositionAllow
|
||||||
dispositionDeny = servewaf.DispositionDeny
|
dispositionDeny = discriminator.DispositionDeny
|
||||||
dispositionClose = servewaf.DispositionClose
|
dispositionClose = discriminator.DispositionClose
|
||||||
// deniedBody is the body of a deny.
|
// deniedBody is the body of a deny.
|
||||||
deniedBody = "refused by servewaf"
|
deniedBody = "refused by discriminator"
|
||||||
)
|
)
|
||||||
|
|
||||||
// output collects what rproxy writes on stdout.
|
// output collects what rproxy writes on stdout.
|
||||||
@@ -158,7 +158,7 @@ func (l heldLog) Write(p []byte) (int, error) {
|
|||||||
return len(p), nil
|
return len(p), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// question is what the stand-in for servewaf received as a question.
|
// question is what the stand-in for discriminator received as a question.
|
||||||
type question struct {
|
type question struct {
|
||||||
method, target, host string
|
method, target, host string
|
||||||
header http.Header
|
header http.Header
|
||||||
@@ -167,26 +167,26 @@ type question struct {
|
|||||||
transferEncoding []string
|
transferEncoding []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveWAF is a stand-in for servewaf that answers each question as answer
|
// Discriminator is a stand-in for discriminator that answers each question as
|
||||||
// does, and keeps the questions and the reports it gets.
|
// answer does, and keeps the questions and the reports it gets.
|
||||||
type serveWAF struct {
|
type Discriminator struct {
|
||||||
*httptest.Server
|
*httptest.Server
|
||||||
|
|
||||||
questions chan question
|
questions chan question
|
||||||
reports chan servewaf.Report
|
reports chan discriminator.Report
|
||||||
}
|
}
|
||||||
|
|
||||||
// startServeWAF starts a stand-in for servewaf.
|
// startDiscriminator starts a stand-in for discriminator.
|
||||||
func startServeWAF(t *testing.T, answer http.HandlerFunc) *serveWAF {
|
func startDiscriminator(t *testing.T, answer http.HandlerFunc) *Discriminator {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
s := &serveWAF{
|
s := &Discriminator{
|
||||||
questions: make(chan question, 10),
|
questions: make(chan question, 10),
|
||||||
reports: make(chan servewaf.Report, 10),
|
reports: make(chan discriminator.Report, 10),
|
||||||
}
|
}
|
||||||
s.Server = httptest.NewServer(http.HandlerFunc(
|
s.Server = httptest.NewServer(http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Header.Get(servewaf.ClientHeader) == "" {
|
if r.Header.Get(discriminator.ClientHeader) == "" {
|
||||||
s.takeReport(t, w, r)
|
s.takeReport(t, w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -209,14 +209,17 @@ func startServeWAF(t *testing.T, answer http.HandlerFunc) *serveWAF {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// takeReport keeps a report and answers it as servewaf does.
|
// takeReport keeps a report and answers it as discriminator does.
|
||||||
func (s *serveWAF) takeReport(t *testing.T, w http.ResponseWriter, r *http.Request) {
|
func (s *Discriminator) takeReport(
|
||||||
|
t *testing.T, w http.ResponseWriter, r *http.Request,
|
||||||
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
var report servewaf.Report
|
var report discriminator.Report
|
||||||
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&report)
|
err := json.NewDecoder(r.Body).Decode(&report)
|
||||||
if err != nil || r.Method != http.MethodPost || r.URL.Path != servewaf.ReportPath {
|
if err != nil || r.Method != http.MethodPost ||
|
||||||
|
r.URL.Path != discriminator.ReportPath {
|
||||||
t.Errorf("%s %s is no report: %v", r.Method, r.URL.Path, err)
|
t.Errorf("%s %s is no report: %v", r.Method, r.URL.Path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +229,7 @@ func (s *serveWAF) takeReport(t *testing.T, w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wantNoReport checks that no report comes for a moment.
|
// wantNoReport checks that no report comes for a moment.
|
||||||
func (s *serveWAF) wantNoReport(t *testing.T) {
|
func (s *Discriminator) wantNoReport(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@@ -237,7 +240,7 @@ func (s *serveWAF) wantNoReport(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// report waits for a report.
|
// report waits for a report.
|
||||||
func (s *serveWAF) report(t *testing.T) servewaf.Report {
|
func (s *Discriminator) report(t *testing.T) discriminator.Report {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@@ -246,12 +249,12 @@ func (s *serveWAF) report(t *testing.T) servewaf.Report {
|
|||||||
case <-time.After(waitLimit):
|
case <-time.After(waitLimit):
|
||||||
t.Fatal("no report")
|
t.Fatal("no report")
|
||||||
|
|
||||||
return servewaf.Report{}
|
return discriminator.Report{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// question waits for a question.
|
// question waits for a question.
|
||||||
func (s *serveWAF) question(t *testing.T) question {
|
func (s *Discriminator) question(t *testing.T) question {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@@ -264,16 +267,17 @@ func (s *serveWAF) question(t *testing.T) question {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startServeWAFAnsweringReports starts a stand-in for servewaf that answers each
|
// startDiscriminatorAnsweringReports starts a stand-in for discriminator that
|
||||||
// question as answer does and each report as report does, and returns its URL.
|
// answers each question as answer does and each report as report does, and
|
||||||
func startServeWAFAnsweringReports(
|
// returns its URL.
|
||||||
|
func startDiscriminatorAnsweringReports(
|
||||||
t *testing.T, answer, report http.HandlerFunc,
|
t *testing.T, answer, report http.HandlerFunc,
|
||||||
) string {
|
) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
backend := httptest.NewServer(http.HandlerFunc(
|
backend := httptest.NewServer(http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Header.Get(servewaf.ClientHeader) != "" {
|
if r.Header.Get(discriminator.ClientHeader) != "" {
|
||||||
answer(w, r)
|
answer(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -295,7 +299,7 @@ func allow(w http.ResponseWriter, _ *http.Request) {
|
|||||||
// deny answers a question with deny, status 403 and a body of its own.
|
// deny answers a question with deny, status 403 and a body of its own.
|
||||||
func deny(w http.ResponseWriter, _ *http.Request) {
|
func deny(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set(dispositionHeader, dispositionDeny)
|
w.Header().Set(dispositionHeader, dispositionDeny)
|
||||||
w.Header().Set(fromServeWAFHeader, addedHeaderValue)
|
w.Header().Set(fromDiscriminatorHeader, addedHeaderValue)
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
_, _ = io.WriteString(w, deniedBody)
|
_, _ = io.WriteString(w, deniedBody)
|
||||||
}
|
}
|
||||||
@@ -316,7 +320,7 @@ func greet(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = io.WriteString(w, greeting)
|
_, _ = io.WriteString(w, greeting)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startRProxy starts rproxy with servewaf at backend and the app at appURL, and
|
// startRProxy starts rproxy with discriminator at backend and the app at appURL, and
|
||||||
// the settings in env on top of the defaults. It returns where rproxy listens
|
// the settings in env on top of the defaults. It returns where rproxy listens
|
||||||
// and what it writes.
|
// and what it writes.
|
||||||
func startRProxy(
|
func startRProxy(
|
||||||
@@ -498,7 +502,7 @@ func readStatus(t *testing.T, conn net.Conn) int {
|
|||||||
func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
|
func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, func(w http.ResponseWriter, r *http.Request) {
|
standIn := startDiscriminator(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set(asnHeader, "AS64496")
|
w.Header().Set(asnHeader, "AS64496")
|
||||||
w.Header().Set(countryHeader, "DE")
|
w.Header().Set(countryHeader, "DE")
|
||||||
allow(w, r)
|
allow(w, r)
|
||||||
@@ -557,7 +561,7 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
|
|||||||
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
|
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
|
||||||
}
|
}
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
seen := make(chan http.Header, 1)
|
seen := make(chan http.Header, 1)
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
seen <- r.Header.Clone()
|
seen <- r.Header.Clone()
|
||||||
@@ -602,7 +606,7 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
|
|||||||
func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
|
func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
seen := make(chan http.Header, 1)
|
seen := make(chan http.Header, 1)
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
seen <- r.Header.Clone()
|
seen <- r.Header.Clone()
|
||||||
@@ -641,7 +645,7 @@ func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
|
|||||||
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
|
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header()["Content-Type"] = nil
|
w.Header()["Content-Type"] = nil
|
||||||
_, _ = io.WriteString(w, "<html>")
|
_, _ = io.WriteString(w, "<html>")
|
||||||
@@ -663,19 +667,19 @@ func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRemovesTheVisitorsLookupHeadersWhenServeWAFGivesNone(t *testing.T) {
|
func TestRemovesTheVisitorsLookupHeadersWhenDiscriminatorGivesNone(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
backend func(t *testing.T) string
|
backend func(t *testing.T) string
|
||||||
}{
|
}{
|
||||||
{"servewaf allows", func(t *testing.T) string {
|
{"discriminator allows", func(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return startServeWAF(t, allow).URL
|
return startDiscriminator(t, allow).URL
|
||||||
}},
|
}},
|
||||||
{"servewaf is down", func(*testing.T) string { return nowhere }},
|
{"discriminator is down", func(*testing.T) string { return nowhere }},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -701,10 +705,10 @@ func TestRemovesTheVisitorsLookupHeadersWhenServeWAFGivesNone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) {
|
func TestDenyGivesTheVisitorDiscriminatorsAnswer(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, deny)
|
standIn := startDiscriminator(t, deny)
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
t.Error("the app was asked")
|
t.Error("the app was asked")
|
||||||
})
|
})
|
||||||
@@ -712,8 +716,8 @@ func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) {
|
|||||||
|
|
||||||
got := get(t, addr, "/")
|
got := get(t, addr, "/")
|
||||||
if got.status != http.StatusForbidden || got.body != deniedBody ||
|
if got.status != http.StatusForbidden || got.body != deniedBody ||
|
||||||
got.header.Get(fromServeWAFHeader) != addedHeaderValue {
|
got.header.Get(fromDiscriminatorHeader) != addedHeaderValue {
|
||||||
t.Errorf("answered %+v, want servewaf's answer", got)
|
t.Errorf("answered %+v, want discriminator's answer", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(got.header.Values(dispositionHeader)) != 0 {
|
if len(got.header.Values(dispositionHeader)) != 0 {
|
||||||
@@ -729,21 +733,21 @@ func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) {
|
|||||||
|
|
||||||
line := out.requestLine(t)
|
line := out.requestLine(t)
|
||||||
if line["disposition"] != dispositionDeny || line["default_disposition"] != nil {
|
if line["disposition"] != dispositionDeny || line["default_disposition"] != nil {
|
||||||
t.Errorf("line %v, want servewaf's deny", line)
|
t.Errorf("line %v, want discriminator's deny", line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestADeniedHeadKeepsServeWAFsContentLength(t *testing.T) {
|
func TestADeniedHeadKeepsDiscriminatorsContentLength(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, deny)
|
standIn := startDiscriminator(t, deny)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody))
|
got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody))
|
||||||
if got.status != http.StatusForbidden ||
|
if got.status != http.StatusForbidden ||
|
||||||
got.header.Get(contentLengthHeader) != strconv.Itoa(len(deniedBody)) {
|
got.header.Get(contentLengthHeader) != strconv.Itoa(len(deniedBody)) {
|
||||||
t.Errorf("answered %+v, want servewaf's 403 and its length", got)
|
t.Errorf("answered %+v, want discriminator's 403 and its length", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,13 +762,13 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"a deny", "GET /" + host, deniedBody, http.StatusForbidden},
|
{"a deny", "GET /" + host, deniedBody, http.StatusForbidden},
|
||||||
{"rproxy's own answer", "GET x:admin" + host, "Bad Request\n", http.StatusBadRequest},
|
{"rproxy's own answer", "GET x:admin" + host, "Bad Request\n", http.StatusBadRequest},
|
||||||
{"the health check", "GET " + servewaf.HealthPath + host, "ok\n", http.StatusOK},
|
{"the health check", "GET " + discriminator.HealthPath + host, "ok\n", http.StatusOK},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
log := heldLog{release: make(chan struct{})}
|
log := heldLog{release: make(chan struct{})}
|
||||||
standIn := startServeWAF(t, deny)
|
standIn := startDiscriminator(t, deny)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startServerLogging(t, standIn.URL, app.URL, nil, log, io.Discard)
|
addr, _ := startServerLogging(t, standIn.URL, app.URL, nil, log, io.Discard)
|
||||||
|
|
||||||
@@ -794,7 +798,7 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) {
|
|||||||
func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
|
func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
@@ -817,7 +821,7 @@ func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
|
|||||||
func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set(dispositionHeader, dispositionClose)
|
w.Header().Set(dispositionHeader, dispositionClose)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
@@ -849,7 +853,7 @@ func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
|||||||
func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
|
func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
backend := startServeWAFAnsweringReports(t,
|
backend := startDiscriminatorAnsweringReports(t,
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set(dispositionHeader, dispositionClose)
|
w.Header().Set(dispositionHeader, dispositionClose)
|
||||||
},
|
},
|
||||||
@@ -874,17 +878,17 @@ func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// answeringWith returns what starts a stand-in for servewaf that answers each
|
// answeringWith returns what starts a stand-in for discriminator that answers each
|
||||||
// question as answer does, and returns its URL.
|
// question as answer does, and returns its URL.
|
||||||
func answeringWith(answer http.HandlerFunc) func(t *testing.T) string {
|
func answeringWith(answer http.HandlerFunc) func(t *testing.T) string {
|
||||||
return func(t *testing.T) string {
|
return func(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return startServeWAF(t, answer).URL
|
return startDiscriminator(t, answer).URL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// noAnswers are the ways servewaf can leave the decision to the default.
|
// noAnswers are the ways discriminator can leave the decision to the default.
|
||||||
func noAnswers() map[string]func(t *testing.T) string {
|
func noAnswers() map[string]func(t *testing.T) string {
|
||||||
return map[string]func(t *testing.T) string{
|
return map[string]func(t *testing.T) string{
|
||||||
"no connection": func(*testing.T) string { return nowhere },
|
"no connection": func(*testing.T) string { return nowhere },
|
||||||
@@ -916,7 +920,7 @@ func noAnswers() map[string]func(t *testing.T) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) {
|
func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -925,7 +929,7 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"a refusal", dispositionDeny, deniedBody, http.StatusForbidden},
|
{"a refusal", dispositionDeny, deniedBody, http.StatusForbidden},
|
||||||
{"a refusal, 429", dispositionDeny, deniedBody, http.StatusTooManyRequests},
|
{"a refusal, 429", dispositionDeny, deniedBody, http.StatusTooManyRequests},
|
||||||
{"servewaf's 400", "", "refused\n", http.StatusBadRequest},
|
{"discriminator's 400", "", "refused\n", http.StatusBadRequest},
|
||||||
{"a 500", "", "broken\n", http.StatusInternalServerError},
|
{"a 500", "", "broken\n", http.StatusInternalServerError},
|
||||||
{"a 503 denied", dispositionDeny, "busy\n", http.StatusServiceUnavailable},
|
{"a 503 denied", dispositionDeny, "busy\n", http.StatusServiceUnavailable},
|
||||||
{"no header", "", "who knows\n", http.StatusOK},
|
{"no header", "", "who knows\n", http.StatusOK},
|
||||||
@@ -935,12 +939,12 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) {
|
|||||||
t.Run(tc.name+" with the default "+setting, func(t *testing.T) {
|
t.Run(tc.name+" with the default "+setting, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
if tc.disposition != "" {
|
if tc.disposition != "" {
|
||||||
w.Header().Set(dispositionHeader, tc.disposition)
|
w.Header().Set(dispositionHeader, tc.disposition)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set(fromServeWAFHeader, addedHeaderValue)
|
w.Header().Set(fromDiscriminatorHeader, addedHeaderValue)
|
||||||
w.WriteHeader(tc.status)
|
w.WriteHeader(tc.status)
|
||||||
_, _ = io.WriteString(w, tc.body)
|
_, _ = io.WriteString(w, tc.body)
|
||||||
})
|
})
|
||||||
@@ -953,8 +957,8 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) {
|
|||||||
|
|
||||||
got := get(t, addr, "/")
|
got := get(t, addr, "/")
|
||||||
if got.status != tc.status || got.body != tc.body ||
|
if got.status != tc.status || got.body != tc.body ||
|
||||||
got.header.Get(fromServeWAFHeader) != addedHeaderValue {
|
got.header.Get(fromDiscriminatorHeader) != addedHeaderValue {
|
||||||
t.Errorf("answered %+v, want servewaf's answer", got)
|
t.Errorf("answered %+v, want discriminator's answer", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(got.header.Values(dispositionHeader)) != 0 {
|
if len(got.header.Values(dispositionHeader)) != 0 {
|
||||||
@@ -966,8 +970,8 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) {
|
|||||||
t.Errorf("line %v, want status %d and no default", line, tc.status)
|
t.Errorf("line %v, want status %d and no default", line, tc.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if out.has("servewaf gave no answer") {
|
if out.has("discriminator gave no answer") {
|
||||||
t.Error("rproxy warned that servewaf gave no answer")
|
t.Error("rproxy warned that discriminator gave no answer")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -977,14 +981,14 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) {
|
|||||||
func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
|
func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
http.Error(w, "refused", http.StatusBadRequest)
|
http.Error(w, "refused", http.StatusBadRequest)
|
||||||
})
|
})
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
if got := get(t, addr, "/"); got.status != http.StatusBadRequest {
|
if got := get(t, addr, "/"); got.status != http.StatusBadRequest {
|
||||||
t.Errorf("answered %+v, want servewaf's 400", got)
|
t.Errorf("answered %+v, want discriminator's 400", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if line := out.requestLine(t); line["disposition"] != nil {
|
if line := out.requestLine(t); line["disposition"] != nil {
|
||||||
@@ -997,7 +1001,7 @@ func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
|
|||||||
func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) {
|
func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header()["Content-Type"] = nil
|
w.Header()["Content-Type"] = nil
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
_, _ = io.WriteString(w, "<html>broken")
|
_, _ = io.WriteString(w, "<html>broken")
|
||||||
@@ -1015,7 +1019,7 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
|
|||||||
|
|
||||||
const size = 1 << 20
|
const size = 1 << 20
|
||||||
|
|
||||||
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
_, _ = w.Write(make([]byte, size))
|
_, _ = w.Write(make([]byte, size))
|
||||||
})
|
})
|
||||||
@@ -1023,15 +1027,15 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
|
|||||||
|
|
||||||
got := get(t, addr, "/")
|
got := get(t, addr, "/")
|
||||||
if got.status != http.StatusInternalServerError || len(got.body) != size {
|
if got.status != http.StatusInternalServerError || len(got.body) != size {
|
||||||
t.Errorf("answered %d with %d bytes, want servewaf's 500 with %d",
|
t.Errorf("answered %d with %d bytes, want discriminator's 500 with %d",
|
||||||
got.status, len(got.body), size)
|
got.status, len(got.body), size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startServeWAFWriting starts a stand-in for servewaf that reads each question and
|
// startDiscriminatorWriting starts a stand-in for discriminator that reads
|
||||||
// answers it with text, as it is, and returns its URL. It can send what Go's
|
// each question and answers it with text, as it is, and returns its URL. It
|
||||||
// server would not.
|
// can send what Go's server would not.
|
||||||
func startServeWAFWriting(t *testing.T, text string) string {
|
func startDiscriminatorWriting(t *testing.T, text string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||||
@@ -1101,7 +1105,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
|||||||
"a status below 100": func(t *testing.T) string {
|
"a status below 100": func(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return startServeWAFWriting(t, "HTTP/1.1 099 Odd\r\nContent-Length: 0\r\n\r\n")
|
return startDiscriminatorWriting(t, "HTTP/1.1 099 Odd\r\nContent-Length: 0\r\n\r\n")
|
||||||
},
|
},
|
||||||
"a 101 that holds its connection open": answeringWith(switchAndHold),
|
"a 101 that holds its connection open": answeringWith(switchAndHold),
|
||||||
} {
|
} {
|
||||||
@@ -1126,7 +1130,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
|||||||
t.Errorf("line %v, want status 502 and no disposition", line)
|
t.Errorf("line %v, want status 502 and no disposition", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
out.waitFor(t, "servewaf's answer is dropped")
|
out.waitFor(t, "discriminator's answer is dropped")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1135,7 +1139,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
|||||||
func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
|
func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, overOneMiB(dispositionDeny))
|
standIn := startDiscriminator(t, overOneMiB(dispositionDeny))
|
||||||
addr, out := startRProxy(t, standIn.URL, nowhere, nil)
|
addr, out := startRProxy(t, standIn.URL, nowhere, nil)
|
||||||
|
|
||||||
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
|
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
|
||||||
@@ -1192,14 +1196,14 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAVisitorThatGoesAwayWhileServeWAFIsAskedGetsNoDefault(t *testing.T) {
|
func TestAVisitorThatGoesAwayWhileDiscriminatorIsAskedGetsNoDefault(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, disposition := range []string{dispositionAllow, dispositionDeny} {
|
for _, disposition := range []string{dispositionAllow, dispositionDeny} {
|
||||||
t.Run("with the default "+disposition, func(t *testing.T) {
|
t.Run("with the default "+disposition, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) {
|
standIn := startDiscriminator(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||||
<-r.Context().Done()
|
<-r.Context().Done()
|
||||||
})
|
})
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
@@ -1222,8 +1226,8 @@ func TestAVisitorThatGoesAwayWhileServeWAFIsAskedGetsNoDefault(t *testing.T) {
|
|||||||
t.Errorf("line %v, want status 0 and no disposition", line)
|
t.Errorf("line %v, want status 0 and no disposition", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
if out.has("servewaf gave no answer") {
|
if out.has("discriminator gave no answer") {
|
||||||
t.Error("rproxy warned that servewaf gave no answer")
|
t.Error("rproxy warned that discriminator gave no answer")
|
||||||
}
|
}
|
||||||
|
|
||||||
standIn.wantNoReport(t)
|
standIn.wantNoReport(t)
|
||||||
@@ -1238,13 +1242,13 @@ func TestRProxyWarnsWhenTheDefaultDecides(t *testing.T) {
|
|||||||
addr, out := startRProxy(t, nowhere, app.URL, nil)
|
addr, out := startRProxy(t, nowhere, app.URL, nil)
|
||||||
|
|
||||||
wantGreeting(t, get(t, addr, "/"))
|
wantGreeting(t, get(t, addr, "/"))
|
||||||
out.waitFor(t, "servewaf gave no answer")
|
out.waitFor(t, "discriminator gave no answer")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
|
func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) {
|
standIn := startDiscriminator(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||||
<-r.Context().Done()
|
<-r.Context().Done()
|
||||||
})
|
})
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
@@ -1265,35 +1269,35 @@ func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) {
|
|||||||
})
|
})
|
||||||
addr, _ := startRProxy(t, nowhere, app.URL, nil)
|
addr, _ := startRProxy(t, nowhere, app.URL, nil)
|
||||||
|
|
||||||
got := get(t, addr, "/_smallwebwaf/metrics")
|
got := get(t, addr, "/_sww/metrics")
|
||||||
if got.status != http.StatusNotFound {
|
if got.status != http.StatusNotFound {
|
||||||
t.Errorf("answered %+v, want 404", got)
|
t.Errorf("answered %+v, want 404", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHealthCheckIsAnsweredWhileServeWAFIsDown(t *testing.T) {
|
func TestHealthCheckIsAnsweredWhileDiscriminatorIsDown(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, nowhere, app.URL, nil)
|
addr, out := startRProxy(t, nowhere, app.URL, nil)
|
||||||
|
|
||||||
got := get(t, addr, servewaf.HealthPath)
|
got := get(t, addr, discriminator.HealthPath)
|
||||||
if got.status != http.StatusOK || got.body != "ok\n" {
|
if got.status != http.StatusOK || got.body != "ok\n" {
|
||||||
t.Errorf("answered %+v, want ok", got)
|
t.Errorf("answered %+v, want ok", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
line := out.requestLine(t)
|
line := out.requestLine(t)
|
||||||
if line["path"] != servewaf.HealthPath || line["disposition"] != nil {
|
if line["path"] != discriminator.HealthPath || line["disposition"] != nil {
|
||||||
t.Errorf("line %v, want one with no disposition", line)
|
t.Errorf("line %v, want one with no disposition", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
// With servewaf up too, it is not asked.
|
// With discriminator up too, it is not asked.
|
||||||
addr, _ = startRProxy(t, standIn.URL, app.URL, nil)
|
addr, _ = startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
get(t, addr, servewaf.HealthPath)
|
get(t, addr, discriminator.HealthPath)
|
||||||
|
|
||||||
if len(standIn.questions) != 0 {
|
if len(standIn.questions) != 0 {
|
||||||
t.Error("servewaf was asked about the health check")
|
t.Error("discriminator was asked about the health check")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1301,7 +1305,7 @@ func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
backend := startServeWAFAnsweringReports(t, deny,
|
backend := startDiscriminatorAnsweringReports(t, deny,
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
select {
|
select {
|
||||||
case <-release:
|
case <-release:
|
||||||
@@ -1336,16 +1340,16 @@ func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
|
|||||||
_ = res.Body.Close()
|
_ = res.Body.Close()
|
||||||
|
|
||||||
if res.StatusCode != http.StatusForbidden {
|
if res.StatusCode != http.StatusForbidden {
|
||||||
t.Errorf("status %d, want servewaf's 403", res.StatusCode)
|
t.Errorf("status %d, want discriminator's 403", res.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAReportServeWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
|
func TestAReportDiscriminatorLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
backend := startServeWAFAnsweringReports(t, allow,
|
backend := startDiscriminatorAnsweringReports(t, allow,
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
<-release
|
<-release
|
||||||
|
|
||||||
@@ -1365,10 +1369,10 @@ func TestAReportServeWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
|
|||||||
out.waitFor(t, "a report is dropped")
|
out.waitFor(t, "a report is dropped")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAReportServeWAFRefusesIsDroppedWithAWarning(t *testing.T) {
|
func TestAReportDiscriminatorRefusesIsDroppedWithAWarning(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
backend := startServeWAFAnsweringReports(t, allow,
|
backend := startDiscriminatorAnsweringReports(t, allow,
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
})
|
})
|
||||||
@@ -1383,7 +1387,7 @@ func TestAReportServeWAFRefusesIsDroppedWithAWarning(t *testing.T) {
|
|||||||
func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
|
func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
backend := startServeWAFAnsweringReports(t, allow, switchAndHold)
|
backend := startDiscriminatorAnsweringReports(t, allow, switchAndHold)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
||||||
timeoutSetting: "60000",
|
timeoutSetting: "60000",
|
||||||
@@ -1396,7 +1400,7 @@ func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
|
|||||||
func TestAReportDueAfterWaitForReportsIsDropped(t *testing.T) {
|
func TestAReportDueAfterWaitForReportsIsDropped(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out, server := startServer(t, standIn.URL, app.URL, nil)
|
addr, out, server := startServer(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
@@ -1412,7 +1416,7 @@ func TestReportGivesWhatHappened(t *testing.T) {
|
|||||||
|
|
||||||
const body = "the visitor's body"
|
const body = "the visitor's body"
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
@@ -1442,7 +1446,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
|
|||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
backend := httptest.NewServer(http.HandlerFunc(
|
backend := httptest.NewServer(http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Header.Get(servewaf.ClientHeader) != "" {
|
if r.Header.Get(discriminator.ClientHeader) != "" {
|
||||||
allow(w, r)
|
allow(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1497,7 +1501,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
|
|||||||
func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) {
|
func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, echoAfterUpgrade)
|
app := startApp(t, echoAfterUpgrade)
|
||||||
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
maxRequestTime: "2000",
|
maxRequestTime: "2000",
|
||||||
@@ -1552,7 +1556,7 @@ func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
|
|||||||
|
|
||||||
const goodbye = "sent after the visitor stopped sending\n"
|
const goodbye = "sent after the visitor stopped sending\n"
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
conn, buffered, err := http.NewResponseController(w).Hijack()
|
conn, buffered, err := http.NewResponseController(w).Hijack()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1610,7 +1614,7 @@ func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
|
|||||||
//nolint:paralleltest // see above
|
//nolint:paralleltest // see above
|
||||||
func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) {
|
func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) {
|
||||||
closed := make(chan error, 1)
|
closed := make(chan error, 1)
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
conn, buffered, err := http.NewResponseController(w).Hijack()
|
conn, buffered, err := http.NewResponseController(w).Hijack()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1681,7 +1685,7 @@ func TestEachRequestGetsAFreshID(t *testing.T) {
|
|||||||
|
|
||||||
const fromTraefik = "an-id-from-traefik"
|
const fromTraefik = "an-id-from-traefik"
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
trustedProxies: localhost + "/32",
|
trustedProxies: localhost + "/32",
|
||||||
@@ -1711,7 +1715,7 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) {
|
|||||||
body = "the visitor's body"
|
body = "the visitor's body"
|
||||||
)
|
)
|
||||||
|
|
||||||
standIn := startServeWAF(t, allow)
|
standIn := startDiscriminator(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package rules reads the rule files: the plain text files in
|
// Package rules reads the rule files: the plain text files in
|
||||||
// SWWAF_RULES_DIR, one rule to a line, that each request is checked
|
// SWW_RULES_DIR, one rule to a line, that each request is checked
|
||||||
// against, as the "Rule files" section of SPEC.md describes. They are read
|
// against, as the "Rule files" section of SPEC.md describes. They are read
|
||||||
// at start, and again once the directory has had no change for a short
|
// at start, and again once the directory has had no change for a short
|
||||||
// time after one is edited, added or removed.
|
// time after one is edited, added or removed.
|
||||||
@@ -40,7 +40,7 @@ const (
|
|||||||
// extension ends the name of every rule file.
|
// extension ends the name of every rule file.
|
||||||
const extension = ".rules"
|
const extension = ".rules"
|
||||||
|
|
||||||
// quietTime is how long SWWAF_RULES_DIR must go without a change before
|
// quietTime is how long SWW_RULES_DIR must go without a change before
|
||||||
// the rule files are read again, so that a file still being written, such
|
// the rule files are read again, so that a file still being written, such
|
||||||
// as one saved in place, appended to or copied in with scp, is read only
|
// as one saved in place, appended to or copied in with scp, is read only
|
||||||
// once whole.
|
// once whole.
|
||||||
@@ -100,19 +100,19 @@ type Rule struct {
|
|||||||
|
|
||||||
// Params are what Load needs.
|
// Params are what Load needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// Dir is the directory of the rule files (SWWAF_RULES_DIR).
|
// Dir is the directory of the rule files (SWW_RULES_DIR).
|
||||||
Dir string
|
Dir string
|
||||||
// Enabled is SWWAF_RULES_ENABLED: while it is false, no file is read
|
// Enabled is SWW_RULES_ENABLED: while it is false, no file is read
|
||||||
// and no rule loaded.
|
// and no rule loaded.
|
||||||
Enabled bool
|
Enabled bool
|
||||||
// ProcessLog receives how many rules were read, and the error in a
|
// ProcessLog receives how many rules were read, and the error in a
|
||||||
// rule file edited while smallwebwaf runs.
|
// rule file edited while sww runs.
|
||||||
ProcessLog *slog.Logger
|
ProcessLog *slog.Logger
|
||||||
// Alerts receive a file_error alert for that error.
|
// Alerts receive a file_error alert for that error.
|
||||||
Alerts *alerts.Queue
|
Alerts *alerts.Queue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files are the rule files of a running smallwebwaf, and the rules read
|
// Files are the rule files of a running sww, and the rules read
|
||||||
// from them. They are safe for concurrent use.
|
// from them. They are safe for concurrent use.
|
||||||
type Files struct {
|
type Files struct {
|
||||||
params Params
|
params Params
|
||||||
@@ -271,7 +271,7 @@ func (f *Files) logRead(count int) {
|
|||||||
func read(dir string) ([]Rule, string, error) {
|
func read(dir string) ([]Rule, string, error) {
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, dir, fmt.Errorf("SWWAF_RULES_DIR cannot be read: %w", err)
|
return nil, dir, fmt.Errorf("SWW_RULES_DIR cannot be read: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var rules []Rule
|
var rules []Rule
|
||||||
@@ -299,7 +299,7 @@ func read(dir string) ([]Rule, string, error) {
|
|||||||
// readFile appends the rules of the rule file at path to rules. places
|
// readFile appends the rules of the rule file at path to rules. places
|
||||||
// are where each id read so far is, and gain those of the file.
|
// are where each id read so far is, and gain those of the file.
|
||||||
func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) {
|
func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) {
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWWAF_RULES_DIR
|
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWW_RULES_DIR
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
|
|||||||
|
|
||||||
dir := filepath.Join(t.TempDir(), "rules.d")
|
dir := filepath.Join(t.TempDir(), "rules.d")
|
||||||
|
|
||||||
wantRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+
|
wantRefused(t, dir, "SWW_RULES_DIR cannot be read: open "+dir+
|
||||||
": no such file or directory")
|
": no such file or directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +321,7 @@ func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
|
|||||||
func TestRuleFilesOffReadNothing(t *testing.T) {
|
func TestRuleFilesOffReadNothing(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// SWWAF_RULES_DIR does not exist, which would stop the start.
|
// SWW_RULES_DIR does not exist, which would stop the start.
|
||||||
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
|
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
|
||||||
params.Enabled = false
|
params.Enabled = false
|
||||||
|
|
||||||
@@ -601,7 +601,7 @@ func wantRefused(t *testing.T, dir, want string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// get returns a GET request for target, a path and an optional query, as
|
// get returns a GET request for target, a path and an optional query, as
|
||||||
// smallwebwaf's server reads it, without a user agent.
|
// sww's server reads it, without a user agent.
|
||||||
func get(t *testing.T, target string) *http.Request {
|
func get(t *testing.T, target string) *http.Request {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
+25
-25
@@ -1,5 +1,5 @@
|
|||||||
// Package state keeps smallwebwaf's state in JSON files in
|
// Package state keeps sww's state in JSON files in
|
||||||
// SWWAF_STATE_DIR, as the "Persistent state" section of SPEC.md requires
|
// SWW_STATE_DIR, as the "Persistent state" section of SPEC.md requires
|
||||||
// and docs/state-files.md lists them:
|
// and docs/state-files.md lists them:
|
||||||
// bans.json holds the bans, clients.json each client's counters and
|
// bans.json holds the bans, clients.json each client's counters and
|
||||||
// history, lookups.json GeoJS's answers, reputation.json the last try and
|
// history, lookups.json GeoJS's answers, reputation.json the last try and
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
// cooldowns, the hour under way, the alerts waiting for each destination
|
// cooldowns, the hour under way, the alerts waiting for each destination
|
||||||
// and the anomaly counters. Load
|
// and the anomaly counters. Load
|
||||||
// reads them at start, Watch takes in an admin's edit of one while
|
// reads them at start, Watch takes in an admin's edit of one while
|
||||||
// smallwebwaf runs, and Run and WriteAll write them. The disk is read and
|
// sww runs, and Run and WriteAll write them. The disk is read and
|
||||||
// written outside the parts' locks, which are held only to take a
|
// written outside the parts' locks, which are held only to take a
|
||||||
// snapshot or to put in what a file holds, so that no request waits on
|
// snapshot or to put in what a file holds, so that no request waits on
|
||||||
// the disk.
|
// the disk.
|
||||||
@@ -44,7 +44,7 @@ import (
|
|||||||
// version is the version of the files' format, the only one read.
|
// version is the version of the files' format, the only one read.
|
||||||
const version = 1
|
const version = 1
|
||||||
|
|
||||||
// fileMode lets the smallwebwaf user alone read and write the files, which
|
// fileMode lets the sww user alone read and write the files, which
|
||||||
// hold visitors' addresses.
|
// hold visitors' addresses.
|
||||||
const fileMode = 0o600
|
const fileMode = 0o600
|
||||||
|
|
||||||
@@ -72,16 +72,16 @@ var (
|
|||||||
|
|
||||||
// Params are what Load needs.
|
// Params are what Load needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// Dir is the directory of the state files (SWWAF_STATE_DIR).
|
// Dir is the directory of the state files (SWW_STATE_DIR).
|
||||||
Dir string
|
Dir string
|
||||||
// WriteDelay is how long after a ban is made bans.json is written
|
// WriteDelay is how long after a ban is made bans.json is written
|
||||||
// (SWWAF_STATE_WRITE_DELAY), and CounterInterval how often every file
|
// (SWW_STATE_WRITE_DELAY), and CounterInterval how often every file
|
||||||
// is (SWWAF_STATE_COUNTER_INTERVAL).
|
// is (SWW_STATE_COUNTER_INTERVAL).
|
||||||
WriteDelay time.Duration
|
WriteDelay time.Duration
|
||||||
CounterInterval time.Duration
|
CounterInterval time.Duration
|
||||||
// Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, Alerts and Anomalies
|
// Ledger, Limiter, GeoJS, Lists, DNSBL, AbuseIPDB, Alerts and Anomalies
|
||||||
// hold the state. Alerts also receive a file_error alert for an edit set
|
// hold the state. Alerts also receive a file_error alert for an edit set
|
||||||
// aside, and for a write that fails while smallwebwaf runs.
|
// aside, and for a write that fails while sww runs.
|
||||||
Ledger *bans.Ledger
|
Ledger *bans.Ledger
|
||||||
Limiter *ratelimit.Limiter
|
Limiter *ratelimit.Limiter
|
||||||
GeoJS *lookup.GeoJS
|
GeoJS *lookup.GeoJS
|
||||||
@@ -101,7 +101,7 @@ type Params struct {
|
|||||||
Metrics *metrics.Metrics
|
Metrics *metrics.Metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files are the state files of a running smallwebwaf.
|
// Files are the state files of a running sww.
|
||||||
type Files struct {
|
type Files struct {
|
||||||
params Params
|
params Params
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ type Files struct {
|
|||||||
// it.
|
// it.
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
// sums are the SHA-256 sums of what each file held, by name, when
|
// sums are the SHA-256 sums of what each file held, by name, when
|
||||||
// smallwebwaf last read or wrote it. A file that holds anything else
|
// sww last read or wrote it. A file that holds anything else
|
||||||
// has been edited since.
|
// has been edited since.
|
||||||
sums map[string][sha256.Size]byte
|
sums map[string][sha256.Size]byte
|
||||||
}
|
}
|
||||||
@@ -185,7 +185,7 @@ type stateFile interface {
|
|||||||
func Load(params Params) (*Files, error) {
|
func Load(params Params) (*Files, error) {
|
||||||
err := checkWritable(params.Dir)
|
err := checkWritable(params.Dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWWAF_STATE_DIR cannot be written: %w", err)
|
return nil, fmt.Errorf("SWW_STATE_DIR cannot be written: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
f := &Files{params: params, sums: map[string][sha256.Size]byte{}}
|
f := &Files{params: params, sums: map[string][sha256.Size]byte{}}
|
||||||
@@ -241,7 +241,7 @@ func (f *Files) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteAll writes every state file, as smallwebwaf stops. A file that
|
// WriteAll writes every state file, as sww stops. A file that
|
||||||
// fails does not keep the others from being written.
|
// fails does not keep the others from being written.
|
||||||
func (f *Files) WriteAll() error {
|
func (f *Files) WriteAll() error {
|
||||||
return errors.Join(f.writeFile(bansJSON), f.writeFile(clientsJSON),
|
return errors.Join(f.writeFile(bansJSON), f.writeFile(clientsJSON),
|
||||||
@@ -250,7 +250,7 @@ func (f *Files) WriteAll() error {
|
|||||||
|
|
||||||
// Watch watches Dir until ctx is done, and takes in an admin's edit of a
|
// Watch watches Dir until ctx is done, and takes in an admin's edit of a
|
||||||
// state file as soon as it is saved: what the file holds replaces what
|
// state file as soon as it is saved: what the file holds replaces what
|
||||||
// smallwebwaf held for it. An edit that does not parse is left for the
|
// sww held for it. An edit that does not parse is left for the
|
||||||
// file's next write, which sets it aside, since a file can be read while
|
// file's next write, which sets it aside, since a file can be read while
|
||||||
// an editor is still writing it. If Dir cannot be watched, that is
|
// an editor is still writing it. If Dir cannot be watched, that is
|
||||||
// logged, and an edit is taken in only before its file is written.
|
// logged, and an edit is taken in only before its file is written.
|
||||||
@@ -310,7 +310,7 @@ func (f *Files) logFailure(name string, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fileChanged takes in what the state file name holds, as Watch sees it
|
// fileChanged takes in what the state file name holds, as Watch sees it
|
||||||
// change, if that is an edit made since smallwebwaf last read or wrote
|
// change, if that is an edit made since sww last read or wrote
|
||||||
// the file. A file that cannot be read or does not parse is left for its
|
// the file. A file that cannot be read or does not parse is left for its
|
||||||
// next write.
|
// next write.
|
||||||
func (f *Files) fileChanged(name string) {
|
func (f *Files) fileChanged(name string) {
|
||||||
@@ -326,7 +326,7 @@ func (f *Files) fileChanged(name string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// takeInEdit takes in data, an edit of the state file name, as takeIn
|
// takeInEdit takes in data, an edit of the state file name, as takeIn
|
||||||
// does, and counts and logs it. Every edit taken in while smallwebwaf
|
// does, and counts and logs it. Every edit taken in while sww
|
||||||
// runs, by Watch or by a write, is taken in here. An edit that does not
|
// runs, by Watch or by a write, is taken in here. An edit that does not
|
||||||
// parse is neither counted nor logged, and takeIn's error returned.
|
// parse is neither counted nor logged, and takeIn's error returned.
|
||||||
func (f *Files) takeInEdit(name string, data []byte) error {
|
func (f *Files) takeInEdit(name string, data []byte) error {
|
||||||
@@ -356,13 +356,13 @@ func (f *Files) read(name string) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// readChanged returns what the state file name holds, and whether that
|
// readChanged returns what the state file name holds, and whether that
|
||||||
// has changed since smallwebwaf last read or wrote the file, as it has
|
// has changed since sww last read or wrote the file, as it has
|
||||||
// for a file smallwebwaf never read or wrote. A missing file has not
|
// for a file sww never read or wrote. A missing file has not
|
||||||
// changed: it is written again at its next write.
|
// changed: it is written again at its next write.
|
||||||
func (f *Files) readChanged(name string) ([]byte, bool, error) {
|
func (f *Files) readChanged(name string) ([]byte, bool, error) {
|
||||||
path := filepath.Join(f.params.Dir, name)
|
path := filepath.Join(f.params.Dir, name)
|
||||||
|
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // a state file, in SWWAF_STATE_DIR
|
data, err := os.ReadFile(path) //nolint:gosec // a state file, in SWW_STATE_DIR
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
@@ -377,7 +377,7 @@ func (f *Files) readChanged(name string) ([]byte, bool, error) {
|
|||||||
// takeIn parses data, what the state file name holds, puts it into the
|
// takeIn parses data, what the state file name holds, puts it into the
|
||||||
// part that keeps that state, in place of what the part held, and returns
|
// part that keeps that state, in place of what the part held, and returns
|
||||||
// how many entries the file holds. edit is whether data is an admin's
|
// how many entries the file holds. edit is whether data is an admin's
|
||||||
// edit taken in while smallwebwaf runs, rather than the file read at the
|
// edit taken in while sww runs, rather than the file read at the
|
||||||
// start. An error names the file and, where the JSON decoder tells it,
|
// start. An error names the file and, where the JSON decoder tells it,
|
||||||
// the line and column, or else the entry.
|
// the line and column, or else the entry.
|
||||||
func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) {
|
func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) {
|
||||||
@@ -491,8 +491,8 @@ func (f *Files) takeInAlerts(path string, data []byte) (int, error) {
|
|||||||
return entries, nil
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeFile writes the state file name from what smallwebwaf holds. An
|
// writeFile writes the state file name from what sww holds. An
|
||||||
// edit made since smallwebwaf last read or wrote the file is taken in
|
// edit made since sww last read or wrote the file is taken in
|
||||||
// first, so that it is not overwritten, or set aside if it does not
|
// first, so that it is not overwritten, or set aside if it does not
|
||||||
// parse. A file that cannot be read, or an edit that cannot be set
|
// parse. A file that cannot be read, or an edit that cannot be set
|
||||||
// aside, is left as it is, and the write given up. Every write is counted
|
// aside, is left as it is, and the write given up. Every write is counted
|
||||||
@@ -561,7 +561,7 @@ func (f *Files) setAside(name string, parseErr error) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// encode returns the state file name as smallwebwaf writes it, from a
|
// encode returns the state file name as sww writes it, from a
|
||||||
// snapshot of the part that keeps that state.
|
// snapshot of the part that keeps that state.
|
||||||
func (f *Files) encode(name string) ([]byte, error) {
|
func (f *Files) encode(name string) ([]byte, error) {
|
||||||
switch name {
|
switch name {
|
||||||
@@ -965,7 +965,7 @@ func parse(path string, data []byte, file stateFile) error {
|
|||||||
|
|
||||||
err := json.Unmarshal(data, &header)
|
err := json.Unmarshal(data, &header)
|
||||||
if err == nil && header.Version != version {
|
if err == nil && header.Version != version {
|
||||||
err = fmt.Errorf("%w %d, where this smallwebwaf reads version %d",
|
err = fmt.Errorf("%w %d, where this sww reads version %d",
|
||||||
errVersion, header.Version, version)
|
errVersion, header.Version, version)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1035,7 +1035,7 @@ func write(dir, name string, data []byte) error {
|
|||||||
|
|
||||||
// syncDirectory syncs dir to the disk, so that a rename in it lasts.
|
// syncDirectory syncs dir to the disk, so that a rename in it lasts.
|
||||||
func syncDirectory(dir string) error {
|
func syncDirectory(dir string) error {
|
||||||
directory, err := os.Open(dir) //nolint:gosec // SWWAF_STATE_DIR itself
|
directory, err := os.Open(dir) //nolint:gosec // SWW_STATE_DIR itself
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1045,7 +1045,7 @@ func syncDirectory(dir string) error {
|
|||||||
|
|
||||||
// writeSynced writes data to the file at path, and syncs it to the disk.
|
// writeSynced writes data to the file at path, and syncs it to the disk.
|
||||||
func writeSynced(path string, data []byte) error {
|
func writeSynced(path string, data []byte) error {
|
||||||
//nolint:gosec // a state file's temporary file, in SWWAF_STATE_DIR
|
//nolint:gosec // a state file's temporary file, in SWW_STATE_DIR
|
||||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
|
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ const filledAlertsJSON = `{
|
|||||||
{
|
{
|
||||||
"event": "file_error",
|
"event": "file_error",
|
||||||
"netblock": "",
|
"netblock": "",
|
||||||
"file": "/var/lib/smallwebwaf/bans.json",
|
"file": "/var/lib/sww/bans.json",
|
||||||
"sent": "2026-10-06T00:00:00Z",
|
"sent": "2026-10-06T00:00:00Z",
|
||||||
"suppressed_repeats": 0
|
"suppressed_repeats": 0
|
||||||
},
|
},
|
||||||
@@ -169,7 +169,7 @@ const filledAlertsJSON = `{
|
|||||||
"reason": "writing the state files failed",
|
"reason": "writing the state files failed",
|
||||||
"detail": {
|
"detail": {
|
||||||
"error": "no space left on device",
|
"error": "no space left on device",
|
||||||
"file": "/var/lib/smallwebwaf/bans.json"
|
"file": "/var/lib/sww/bans.json"
|
||||||
},
|
},
|
||||||
"suppressed_repeats": 0
|
"suppressed_repeats": 0
|
||||||
}
|
}
|
||||||
@@ -931,7 +931,7 @@ func TestUnwritableDirectoryStopsTheStart(t *testing.T) {
|
|||||||
filepath.Join(t.TempDir(), "missing"),
|
filepath.Join(t.TempDir(), "missing"),
|
||||||
notADirectory,
|
notADirectory,
|
||||||
} {
|
} {
|
||||||
const want = "SWWAF_STATE_DIR cannot be written: "
|
const want = "SWW_STATE_DIR cannot be written: "
|
||||||
|
|
||||||
_, err := state.Load(newParams(dir))
|
_, err := state.Load(newParams(dir))
|
||||||
if err == nil || !strings.HasPrefix(err.Error(), want) {
|
if err == nil || !strings.HasPrefix(err.Error(), want) {
|
||||||
@@ -1165,13 +1165,13 @@ func TestWritesAreCountedInTheMetrics(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
got := scrape(t, params)
|
got := scrape(t, params)
|
||||||
wantMetric(t, got, "smallwebwaf_state_file_writes_total"+ofBans, 1)
|
wantMetric(t, got, "sww_state_file_writes_total"+ofBans, 1)
|
||||||
wantMetric(t, got, "smallwebwaf_state_file_writes_total"+ofClients, 1)
|
wantMetric(t, got, "sww_state_file_writes_total"+ofClients, 1)
|
||||||
wantMetric(t, got, "smallwebwaf_state_file_write_failures_total"+ofBans, 0)
|
wantMetric(t, got, "sww_state_file_write_failures_total"+ofBans, 0)
|
||||||
wantMetric(t, got, "smallwebwaf_state_file_size_bytes"+ofBans,
|
wantMetric(t, got, "sww_state_file_size_bytes"+ofBans,
|
||||||
float64(len(permanentBansJSON)))
|
float64(len(permanentBansJSON)))
|
||||||
|
|
||||||
written := metric(t, got, "smallwebwaf_state_file_last_write_timestamp_seconds"+ofBans)
|
written := metric(t, got, "sww_state_file_last_write_timestamp_seconds"+ofBans)
|
||||||
if written < float64(time.Now().Add(-time.Hour).Unix()) {
|
if written < float64(time.Now().Add(-time.Hour).Unix()) {
|
||||||
t.Errorf("bans.json was last written at %v, not by that write", written)
|
t.Errorf("bans.json was last written at %v, not by that write", written)
|
||||||
}
|
}
|
||||||
@@ -1192,10 +1192,10 @@ func TestWritesAreCountedInTheMetrics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
got = scrape(t, params)
|
got = scrape(t, params)
|
||||||
wantMetric(t, got, "smallwebwaf_state_file_writes_total"+ofBans, 2)
|
wantMetric(t, got, "sww_state_file_writes_total"+ofBans, 2)
|
||||||
wantMetric(t, got, "smallwebwaf_state_file_write_failures_total"+ofBans, 1)
|
wantMetric(t, got, "sww_state_file_write_failures_total"+ofBans, 1)
|
||||||
wantMetric(t, got, "smallwebwaf_state_file_write_failures_total"+ofClients, 0)
|
wantMetric(t, got, "sww_state_file_write_failures_total"+ofClients, 0)
|
||||||
wantMetric(t, got, "smallwebwaf_state_file_size_bytes"+ofBans,
|
wantMetric(t, got, "sww_state_file_size_bytes"+ofBans,
|
||||||
float64(len(permanentBansJSON)))
|
float64(len(permanentBansJSON)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1486,7 +1486,7 @@ func TestBanWithoutACauseTakenInAsAnAdmins(t *testing.T) {
|
|||||||
"and the reason %q", written, err, reason)
|
"and the reason %q", written, err, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Taken in while smallwebwaf runs, a ban on another netblock is an
|
// Taken in while sww runs, a ban on another netblock is an
|
||||||
// admin's too, and one made since the start.
|
// admin's too, and one made since the start.
|
||||||
watch(t, files, lines)
|
watch(t, files, lines)
|
||||||
edit(t, dir, bansJSON, adminsBansJSON("198.51.100.0/24"))
|
edit(t, dir, bansJSON, adminsBansJSON("198.51.100.0/24"))
|
||||||
@@ -1549,7 +1549,7 @@ func TestBrokenEditSetAsideAtTheNextWrite(t *testing.T) {
|
|||||||
|
|
||||||
watch(t, files, lines)
|
watch(t, files, lines)
|
||||||
|
|
||||||
// While smallwebwaf runs, the broken edit is left as it is: an edit
|
// While sww runs, the broken edit is left as it is: an edit
|
||||||
// of clients.json, made after it and taken in, shows that it has been
|
// of clients.json, made after it and taken in, shows that it has been
|
||||||
// seen.
|
// seen.
|
||||||
edit(t, dir, bansJSON, broken)
|
edit(t, dir, bansJSON, broken)
|
||||||
@@ -1558,7 +1558,7 @@ func TestBrokenEditSetAsideAtTheNextWrite(t *testing.T) {
|
|||||||
wantFiles(t, dir, alertsJSON, bansJSON, clientsJSON, lookupsJSON, reputationJSON)
|
wantFiles(t, dir, alertsJSON, bansJSON, clientsJSON, lookupsJSON, reputationJSON)
|
||||||
|
|
||||||
// The next write sets it aside, logged with where the error is, and
|
// The next write sets it aside, logged with where the error is, and
|
||||||
// writes bans.json again from what smallwebwaf still holds.
|
// writes bans.json again from what sww still holds.
|
||||||
err = files.WriteAll()
|
err = files.WriteAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("write: %v", err)
|
t.Fatalf("write: %v", err)
|
||||||
@@ -1613,7 +1613,7 @@ func TestEditsTakenInAreCountedInTheMetrics(t *testing.T) {
|
|||||||
wantTakenIn(t, lines, dir, bansJSON)
|
wantTakenIn(t, lines, dir, bansJSON)
|
||||||
|
|
||||||
wantMetric(t, scrape(t, params),
|
wantMetric(t, scrape(t, params),
|
||||||
`smallwebwaf_state_file_edits_taken_in_total{file="bans.json",instance="app"}`, 2)
|
`sww_state_file_edits_taken_in_total{file="bans.json",instance="app"}`, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEditTakenInByAWriteIsLoggedAsWatchLogsIt(t *testing.T) {
|
func TestEditTakenInByAWriteIsLoggedAsWatchLogsIt(t *testing.T) {
|
||||||
@@ -1678,7 +1678,7 @@ func TestEditsSetAsideAreCountedInTheMetrics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wantMetric(t, scrape(t, params),
|
wantMetric(t, scrape(t, params),
|
||||||
`smallwebwaf_state_file_edits_set_aside_total{file="bans.json",instance="app"}`, 1)
|
`sww_state_file_edits_set_aside_total{file="bans.json",instance="app"}`, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDirectoryThatCannotBeWatchedIsLogged(t *testing.T) {
|
func TestDirectoryThatCannotBeWatchedIsLogged(t *testing.T) {
|
||||||
@@ -1854,7 +1854,7 @@ func fill(params state.Params) {
|
|||||||
params.Alerts.Raise(alerts.Alert{
|
params.Alerts.Raise(alerts.Alert{
|
||||||
Event: alerts.EventFileError, Reason: "writing the state files failed",
|
Event: alerts.EventFileError, Reason: "writing the state files failed",
|
||||||
Detail: map[string]any{
|
Detail: map[string]any{
|
||||||
"file": "/var/lib/smallwebwaf/bans.json", "error": "no space left on device",
|
"file": "/var/lib/sww/bans.json", "error": "no space left on device",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
params.Alerts.Raise(alerts.Alert{
|
params.Alerts.Raise(alerts.Alert{
|
||||||
@@ -2178,7 +2178,7 @@ func scrape(t *testing.T, params state.Params) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// metric returns the value of series in text, the metrics, such as
|
// metric returns the value of series in text, the metrics, such as
|
||||||
// smallwebwaf_state_file_writes_total{file="bans.json",instance="app"}, or
|
// sww_state_file_writes_total{file="bans.json",instance="app"}, or
|
||||||
// fails the test if there is no such series.
|
// fails the test if there is no such series.
|
||||||
func metric(t *testing.T, text, series string) float64 {
|
func metric(t *testing.T, text, series string) float64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -2210,8 +2210,8 @@ func wantWriteFailed(t *testing.T, params state.Params, name string) {
|
|||||||
got := scrape(t, params)
|
got := scrape(t, params)
|
||||||
file := `{file="` + name + `",instance="app"}`
|
file := `{file="` + name + `",instance="app"}`
|
||||||
|
|
||||||
wantMetric(t, got, "smallwebwaf_state_file_writes_total"+file, 1)
|
wantMetric(t, got, "sww_state_file_writes_total"+file, 1)
|
||||||
wantMetric(t, got, "smallwebwaf_state_file_write_failures_total"+file, 1)
|
wantMetric(t, got, "sww_state_file_write_failures_total"+file, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantMetric checks the value of series in text, the metrics, as metric
|
// wantMetric checks the value of series in text, the metrics, as metric
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package smallwebwaf_test
|
package sww_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -14,14 +14,14 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
|
"sneak.berlin/go/smallwebwaf/internal/sww"
|
||||||
)
|
)
|
||||||
|
|
||||||
// commandForMain, while set in the environment, has this test binary run Main
|
// commandForMain, while set in the environment, has this test binary run Main
|
||||||
// with the command it gives, in place of the tests, so that a test can run
|
// with the command it gives, in place of the tests, so that a test can run
|
||||||
// Main in a process of its own.
|
// Main in a process of its own.
|
||||||
const commandForMain = "SMALLWEBWAF_TEST_COMMAND"
|
const commandForMain = "SWW_TEST_COMMAND"
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
command, set := os.LookupEnv(commandForMain)
|
command, set := os.LookupEnv(commandForMain)
|
||||||
@@ -29,12 +29,12 @@ func TestMain(m *testing.M) {
|
|||||||
os.Exit(m.Run())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
|
|
||||||
os.Args = strings.Fields("smallwebwaf " + command)
|
os.Args = strings.Fields("sww " + command)
|
||||||
|
|
||||||
os.Exit(smallwebwaf.Main(testVersion))
|
os.Exit(sww.Main(testVersion))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMainRunsServeWAF(t *testing.T) {
|
func TestMainRunsDiscriminator(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
binary, err := os.Executable()
|
binary, err := os.Executable()
|
||||||
@@ -50,7 +50,7 @@ func TestMainRunsServeWAF(t *testing.T) {
|
|||||||
cmd.Stdout = out
|
cmd.Stdout = out
|
||||||
|
|
||||||
cmd.Env = append(os.Environ(),
|
cmd.Env = append(os.Environ(),
|
||||||
commandForMain+"="+serveWAFCommand,
|
commandForMain+"="+discriminatorCommand,
|
||||||
listenAddr+"="+localhost+":0",
|
listenAddr+"="+localhost+":0",
|
||||||
stateDir+"="+t.TempDir(),
|
stateDir+"="+t.TempDir(),
|
||||||
rulesDir+"="+t.TempDir(),
|
rulesDir+"="+t.TempDir(),
|
||||||
@@ -67,7 +67,7 @@ func TestMainRunsServeWAF(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
addr, _ := out.line(t, "msg", "starting")["address"].(string)
|
||||||
ask(t, addr, "/", http.StatusOK, servewaf.DispositionAllow)
|
ask(t, addr, "/", http.StatusOK, discriminator.DispositionAllow)
|
||||||
|
|
||||||
err = cmd.Process.Signal(syscall.SIGTERM)
|
err = cmd.Process.Signal(syscall.SIGTERM)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -108,7 +108,7 @@ func TestMainWithoutACommandItKnowsPrintsTheCommands(t *testing.T) {
|
|||||||
t.Errorf("exit status %d (%v), want 2", code, err)
|
t.Errorf("exit status %d (%v), want 2", code, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, name := range []string{"rproxy", serveWAFCommand, "healthcheck"} {
|
for _, name := range []string{"rproxy", discriminatorCommand, "healthcheck"} {
|
||||||
if !strings.Contains(stderr.String(), name) {
|
if !strings.Contains(stderr.String(), name) {
|
||||||
t.Errorf("stderr %q does not name %s", stderr.String(), name)
|
t.Errorf("stderr %q does not name %s", stderr.String(), name)
|
||||||
}
|
}
|
||||||
@@ -121,7 +121,7 @@ func TestMainWithoutACommandItKnowsPrintsTheCommands(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ask sends servewaf at addr rproxy's question about a request for path from
|
// ask sends discriminator at addr rproxy's question about a request for path from
|
||||||
// placed, checks the answer's status and disposition, and closes it.
|
// placed, checks the answer's status and disposition, and closes it.
|
||||||
func ask(t *testing.T, addr, path string, status int, disposition string) {
|
func ask(t *testing.T, addr, path string, status int, disposition string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -132,7 +132,7 @@ func ask(t *testing.T, addr, path string, status int, disposition string) {
|
|||||||
t.Fatalf("new request: %v", err)
|
t.Fatalf("new request: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set(servewaf.ClientHeader, placed)
|
req.Header.Set(discriminator.ClientHeader, placed)
|
||||||
req.Header.Set("X-Request-ID", rand.Text())
|
req.Header.Set("X-Request-ID", rand.Text())
|
||||||
|
|
||||||
transport := &http.Transport{}
|
transport := &http.Transport{}
|
||||||
@@ -147,16 +147,16 @@ func ask(t *testing.T, addr, path string, status int, disposition string) {
|
|||||||
_ = res.Body.Close()
|
_ = res.Body.Close()
|
||||||
|
|
||||||
if res.StatusCode != status ||
|
if res.StatusCode != status ||
|
||||||
res.Header.Get(servewaf.DispositionHeader) != disposition {
|
res.Header.Get(discriminator.DispositionHeader) != disposition {
|
||||||
t.Errorf("answered %d with disposition %q, want %d with %q", res.StatusCode,
|
t.Errorf("answered %d with disposition %q, want %d with %q", res.StatusCode,
|
||||||
res.Header.Get(servewaf.DispositionHeader), status, disposition)
|
res.Header.Get(discriminator.DispositionHeader), status, disposition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runServeWAF runs `smallwebwaf servewaf` with the settings in env, has use
|
// runDiscriminator runs `sww discriminator` with the settings in env, has use
|
||||||
// send it requests at the address it listens on, stops it as SIGTERM does, and
|
// send it requests at the address it listens on, stops it as SIGTERM does, and
|
||||||
// checks that it stopped in order.
|
// checks that it stopped in order.
|
||||||
func runServeWAF(t *testing.T, env map[string]string, use func(addr string)) {
|
func runDiscriminator(t *testing.T, env map[string]string, use func(addr string)) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ctx, stop := context.WithCancel(t.Context())
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
@@ -183,7 +183,7 @@ func runServeWAF(t *testing.T, env map[string]string, use func(addr string)) {
|
|||||||
out.line(t, "msg", "stopped")
|
out.line(t, "msg", "stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServeWAFKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) {
|
func TestDiscriminatorKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
@@ -192,13 +192,13 @@ func TestServeWAFKeepsItsBansAcrossARestartAndNeedsNoApp(t *testing.T) {
|
|||||||
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
|
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
|
||||||
}
|
}
|
||||||
|
|
||||||
runServeWAF(t, env, func(addr string) {
|
runDiscriminator(t, env, func(addr string) {
|
||||||
ask(t, addr, "/", http.StatusOK, servewaf.DispositionAllow)
|
ask(t, addr, "/", http.StatusOK, discriminator.DispositionAllow)
|
||||||
// The default rule file bans a probe for this.
|
// The default rule file bans a probe for this.
|
||||||
ask(t, addr, "/.env", http.StatusForbidden, servewaf.DispositionDeny)
|
ask(t, addr, "/.env", http.StatusForbidden, discriminator.DispositionDeny)
|
||||||
})
|
})
|
||||||
|
|
||||||
runServeWAF(t, env, func(addr string) {
|
runDiscriminator(t, env, func(addr string) {
|
||||||
ask(t, addr, "/", http.StatusForbidden, servewaf.DispositionDeny)
|
ask(t, addr, "/", http.StatusForbidden, discriminator.DispositionDeny)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package smallwebwaf
|
package sww
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
)
|
)
|
||||||
|
|
||||||
// healthCheckTimeout bounds the whole health check.
|
// healthCheckTimeout bounds the whole health check.
|
||||||
@@ -19,13 +19,13 @@ const healthCheckTimeout = 5 * time.Second
|
|||||||
|
|
||||||
var errHealthEndpoint = errors.New("the health endpoint answered")
|
var errHealthEndpoint = errors.New("the health endpoint answered")
|
||||||
|
|
||||||
// HealthCheck is the container's health check. In servewaf's container, where
|
// HealthCheck is the container's health check. In discriminator's container, where
|
||||||
// SMALLWEBWAF_COMMAND is servewaf, it returns 0 while servewaf answers its health
|
// SWW_COMMAND is discriminator, it returns 0 while discriminator answers its health
|
||||||
// endpoint on 127.0.0.1, at the port in SWWAF_LISTEN_ADDR. In rproxy's container,
|
// endpoint on 127.0.0.1, at the port in SWW_LISTEN_ADDR. In rproxy's container,
|
||||||
// where SMALLWEBWAF_COMMAND is rproxy, unset or empty, it returns 0 while rproxy
|
// where SWW_COMMAND is rproxy, unset or empty, it returns 0 while rproxy
|
||||||
// answers its health endpoint on 127.0.0.1, at the port in SWRP_LISTEN_ADDR, and
|
// answers its health endpoint on 127.0.0.1, at the port in SWRP_LISTEN_ADDR, and
|
||||||
// the app accepts connections at the address in SWRP_UPSTREAM_URL. Otherwise,
|
// the app accepts connections at the address in SWRP_UPSTREAM_URL. Otherwise,
|
||||||
// any other value of SMALLWEBWAF_COMMAND included, it writes why to stderr and
|
// any other value of SWW_COMMAND included, it writes why to stderr and
|
||||||
// returns 1. It reads no other setting, nor a file that another names, so neither
|
// returns 1. It reads no other setting, nor a file that another names, so neither
|
||||||
// can fail it.
|
// can fail it.
|
||||||
// args are the arguments after `healthcheck`; it takes none, and given
|
// args are the arguments after `healthcheck`; it takes none, and given
|
||||||
@@ -36,7 +36,7 @@ func HealthCheck(
|
|||||||
) int {
|
) int {
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
_, _ = fmt.Fprintf(stderr,
|
_, _ = fmt.Fprintf(stderr,
|
||||||
"smallwebwaf healthcheck: unexpected argument %q\n", args[0])
|
"sww healthcheck: unexpected argument %q\n", args[0])
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
@@ -49,8 +49,8 @@ func HealthCheck(
|
|||||||
switch {
|
switch {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
err = fmt.Errorf("invalid setting: %w", err)
|
err = fmt.Errorf("invalid setting: %w", err)
|
||||||
case command == "servewaf":
|
case command == "discriminator":
|
||||||
err = checkServeWAF(ctx, lookupEnv)
|
err = checkDiscriminator(ctx, lookupEnv)
|
||||||
default:
|
default:
|
||||||
err = checkRProxyAndApp(ctx, lookupEnv)
|
err = checkRProxyAndApp(ctx, lookupEnv)
|
||||||
}
|
}
|
||||||
@@ -64,16 +64,18 @@ func HealthCheck(
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkServeWAF asks servewaf's health endpoint.
|
// checkDiscriminator asks discriminator's health endpoint.
|
||||||
func checkServeWAF(ctx context.Context, lookupEnv func(string) (string, bool)) error {
|
func checkDiscriminator(
|
||||||
listenAddr, err := config.ServeWAFListenAddr(lookupEnv)
|
ctx context.Context, lookupEnv func(string) (string, bool),
|
||||||
|
) error {
|
||||||
|
listenAddr, err := config.DiscriminatorListenAddr(lookupEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid setting: %w", err)
|
return fmt.Errorf("invalid setting: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = askHealthEndpoint(ctx, listenAddr)
|
err = askHealthEndpoint(ctx, listenAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ask servewaf: %w", err)
|
return fmt.Errorf("ask discriminator: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -107,7 +109,7 @@ func checkRProxyAndApp(
|
|||||||
// listenAddr, which the settings have checked has one.
|
// listenAddr, which the settings have checked has one.
|
||||||
func askHealthEndpoint(ctx context.Context, listenAddr string) error {
|
func askHealthEndpoint(ctx context.Context, listenAddr string) error {
|
||||||
_, port, _ := net.SplitHostPort(listenAddr)
|
_, port, _ := net.SplitHostPort(listenAddr)
|
||||||
health := "http://" + net.JoinHostPort("127.0.0.1", port) + servewaf.HealthPath
|
health := "http://" + net.JoinHostPort("127.0.0.1", port) + discriminator.HealthPath
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, health, http.NoBody)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, health, http.NoBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package smallwebwaf
|
package sww
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package smallwebwaf_test
|
package sww_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -12,18 +12,18 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
|
"sneak.berlin/go/smallwebwaf/internal/sww"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// commandSetting says which command a container runs, and which the health
|
// commandSetting says which command a container runs, and which the health
|
||||||
// check checks.
|
// check checks.
|
||||||
commandSetting = "SMALLWEBWAF_COMMAND"
|
commandSetting = "SWW_COMMAND"
|
||||||
// serveWAFCommand is the command that runs servewaf.
|
// discriminatorCommand is the command that runs discriminator.
|
||||||
serveWAFCommand = "servewaf"
|
discriminatorCommand = "discriminator"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHealthCheckInServeWAFsContainer(t *testing.T) {
|
func TestHealthCheckInDiscriminatorsContainer(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx, stop := context.WithCancel(t.Context())
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
@@ -47,19 +47,19 @@ func TestHealthCheckInServeWAFsContainer(t *testing.T) {
|
|||||||
// The container's settings: an address to listen on with an empty
|
// The container's settings: an address to listen on with an empty
|
||||||
// host part, which the health check asks at 127.0.0.1.
|
// host part, which the health check asks at 127.0.0.1.
|
||||||
wantHealthCheck(t, map[string]string{
|
wantHealthCheck(t, map[string]string{
|
||||||
commandSetting: serveWAFCommand, listenAddr: ":" + port,
|
commandSetting: discriminatorCommand, listenAddr: ":" + port,
|
||||||
}, 0, "")
|
}, 0, "")
|
||||||
|
|
||||||
// The health check reads that setting alone, here given as a file: a removed
|
// The health check reads that setting alone, here given as a file: a removed
|
||||||
// or invalid token file, or an invalid value of another setting, does not
|
// or invalid token file, or an invalid value of another setting, does not
|
||||||
// fail it.
|
// fail it.
|
||||||
for _, other := range []struct{ name, value string }{
|
for _, other := range []struct{ name, value string }{
|
||||||
{"SWWAF_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")},
|
{"SWW_METRICS_TOKEN_FILE", filepath.Join(t.TempDir(), "removed")},
|
||||||
{"SWWAF_METRICS_TOKEN_FILE", writeFile(t, "too short\n")},
|
{"SWW_METRICS_TOKEN_FILE", writeFile(t, "too short\n")},
|
||||||
{"SWWAF_MODE", "neither"},
|
{"SWW_MODE", "neither"},
|
||||||
} {
|
} {
|
||||||
wantHealthCheck(t, map[string]string{
|
wantHealthCheck(t, map[string]string{
|
||||||
commandSetting: serveWAFCommand,
|
commandSetting: discriminatorCommand,
|
||||||
listenAddr + "_FILE": writeFile(t, ":"+port+"\n"),
|
listenAddr + "_FILE": writeFile(t, ":"+port+"\n"),
|
||||||
other.name: other.value,
|
other.name: other.value,
|
||||||
}, 0, "")
|
}, 0, "")
|
||||||
@@ -68,11 +68,11 @@ func TestHealthCheckInServeWAFsContainer(t *testing.T) {
|
|||||||
// Listening on port 0 takes any free port, so nothing listens on port 0
|
// Listening on port 0 takes any free port, so nothing listens on port 0
|
||||||
// itself.
|
// itself.
|
||||||
wantHealthCheck(t, map[string]string{
|
wantHealthCheck(t, map[string]string{
|
||||||
commandSetting: serveWAFCommand, listenAddr: ":0",
|
commandSetting: discriminatorCommand, listenAddr: ":0",
|
||||||
}, 1, "unhealthy: ask servewaf: ")
|
}, 1, "unhealthy: ask discriminator: ")
|
||||||
wantHealthCheck(t, map[string]string{
|
wantHealthCheck(t, map[string]string{
|
||||||
commandSetting: serveWAFCommand, listenAddr: "8080",
|
commandSetting: discriminatorCommand, listenAddr: "8080",
|
||||||
}, 1, "unhealthy: invalid setting: SWWAF_LISTEN_ADDR: ")
|
}, 1, "unhealthy: invalid setting: SWW_LISTEN_ADDR: ")
|
||||||
|
|
||||||
stop()
|
stop()
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
|
|||||||
defer app.Close()
|
defer app.Close()
|
||||||
|
|
||||||
// Nothing listens at the backend. The health check asks rproxy and the app,
|
// Nothing listens at the backend. The health check asks rproxy and the app,
|
||||||
// whether or not servewaf answers.
|
// whether or not discriminator answers.
|
||||||
site, _ := startRProxy(t, "http://127.0.0.1:9", app.URL)
|
site, _ := startRProxy(t, "http://127.0.0.1:9", app.URL)
|
||||||
_, port, _ := net.SplitHostPort(strings.TrimPrefix(site, "http://"))
|
_, port, _ := net.SplitHostPort(strings.TrimPrefix(site, "http://"))
|
||||||
settings := func(more ...string) map[string]string {
|
settings := func(more ...string) map[string]string {
|
||||||
@@ -106,8 +106,8 @@ func TestHealthCheckInRProxysContainer(t *testing.T) {
|
|||||||
wantHealthCheck(t, settings(commandSetting, ""), 0, "")
|
wantHealthCheck(t, settings(commandSetting, ""), 0, "")
|
||||||
wantHealthCheck(t, settings(commandSetting, "rproxy"), 0, "")
|
wantHealthCheck(t, settings(commandSetting, "rproxy"), 0, "")
|
||||||
|
|
||||||
// SMALLWEBWAF_COMMAND cannot be given as a file.
|
// SWW_COMMAND cannot be given as a file.
|
||||||
wantHealthCheck(t, settings(commandSetting+"_FILE", writeFile(t, "servewaf\n")),
|
wantHealthCheck(t, settings(commandSetting+"_FILE", writeFile(t, "discriminator\n")),
|
||||||
0, "")
|
0, "")
|
||||||
|
|
||||||
// Another setting does not fail it, even an invalid one, and neither does
|
// Another setting does not fail it, even an invalid one, and neither does
|
||||||
@@ -136,8 +136,8 @@ func TestHealthCheckFailsWithAnyOtherCommand(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
wantHealthCheck(t, map[string]string{commandSetting: "healthcheck"}, 1,
|
wantHealthCheck(t, map[string]string{commandSetting: "healthcheck"}, 1,
|
||||||
"unhealthy: invalid setting: SMALLWEBWAF_COMMAND: "+
|
"unhealthy: invalid setting: SWW_COMMAND: "+
|
||||||
"\"healthcheck\" is not rproxy or servewaf\n")
|
"\"healthcheck\" is not rproxy or discriminator\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHealthCheckRefusesAnArgument(t *testing.T) {
|
func TestHealthCheckRefusesAnArgument(t *testing.T) {
|
||||||
@@ -149,9 +149,9 @@ func TestHealthCheckRefusesAnArgument(t *testing.T) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
got := smallwebwaf.HealthCheck(t.Context(), []string{"now"}, noSettings, &stderr)
|
got := sww.HealthCheck(t.Context(), []string{"now"}, noSettings, &stderr)
|
||||||
|
|
||||||
want := "smallwebwaf healthcheck: unexpected argument \"now\"\n"
|
want := "sww healthcheck: unexpected argument \"now\"\n"
|
||||||
if got != 1 || stderr.String() != want {
|
if got != 1 || stderr.String() != want {
|
||||||
t.Errorf("health check returned %d and wrote %q, want 1 and %q",
|
t.Errorf("health check returned %d and wrote %q, want 1 and %q",
|
||||||
got, stderr.String(), want)
|
got, stderr.String(), want)
|
||||||
@@ -166,7 +166,7 @@ func wantHealthCheck(t *testing.T, env map[string]string, status int, message st
|
|||||||
|
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
got := smallwebwaf.HealthCheck(t.Context(), nil, func(name string) (string, bool) {
|
got := sww.HealthCheck(t.Context(), nil, func(name string) (string, bool) {
|
||||||
value, ok := env[name]
|
value, ok := env[name]
|
||||||
|
|
||||||
return value, ok
|
return value, ok
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package smallwebwaf
|
package sww
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunRProxy is `smallwebwaf rproxy`: it reads rproxy's settings, then serves
|
// RunRProxy is `sww rproxy`: it reads rproxy's settings, then serves
|
||||||
// requests until ctx is done. Then it gives the requests in progress
|
// requests until ctx is done. Then it gives the requests in progress
|
||||||
// shutdownTimeout to finish, and the reports being sent SWRP_TIMEOUT_MS. It
|
// shutdownTimeout to finish, and the reports being sent SWRP_TIMEOUT_MS. It
|
||||||
// returns the process's exit status, 1 when rproxy cannot start. Its process
|
// returns the process's exit status, 1 when rproxy cannot start. Its process
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package smallwebwaf_test
|
package sww_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -19,18 +19,18 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
|
"sneak.berlin/go/smallwebwaf/internal/sww"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// wafBackend is the setting that names servewaf to rproxy.
|
// wafBackend is the setting that names discriminator to rproxy.
|
||||||
wafBackend = "SWRP_WAF_BACKEND"
|
wafBackend = "SWRP_WAF_BACKEND"
|
||||||
// rproxyListenAddr is the setting that says where rproxy listens.
|
// rproxyListenAddr is the setting that says where rproxy listens.
|
||||||
rproxyListenAddr = "SWRP_LISTEN_ADDR"
|
rproxyListenAddr = "SWRP_LISTEN_ADDR"
|
||||||
// rproxyUpstreamURL is the setting that says where the app is.
|
// rproxyUpstreamURL is the setting that says where the app is.
|
||||||
rproxyUpstreamURL = "SWRP_UPSTREAM_URL"
|
rproxyUpstreamURL = "SWRP_UPSTREAM_URL"
|
||||||
// rproxyTimeout is the setting that says how long rproxy waits for servewaf.
|
// rproxyTimeout is the setting that says how long rproxy waits for discriminator.
|
||||||
rproxyTimeout = "SWRP_TIMEOUT_MS"
|
rproxyTimeout = "SWRP_TIMEOUT_MS"
|
||||||
// bannedProbe is a path the default rule file bans a client for.
|
// bannedProbe is a path the default rule file bans a client for.
|
||||||
bannedProbe = "/.env"
|
bannedProbe = "/.env"
|
||||||
@@ -38,10 +38,10 @@ const (
|
|||||||
otherClient = "198.51.100.7"
|
otherClient = "198.51.100.7"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runRProxy runs `smallwebwaf rproxy` with the settings in env until ctx is
|
// runRProxy runs `sww rproxy` with the settings in env until ctx is
|
||||||
// done, and returns its exit status.
|
// done, and returns its exit status.
|
||||||
func runRProxy(ctx context.Context, env map[string]string, out *output) int {
|
func runRProxy(ctx context.Context, env map[string]string, out *output) int {
|
||||||
return smallwebwaf.RunRProxy(ctx, smallwebwaf.Params{
|
return sww.RunRProxy(ctx, sww.Params{
|
||||||
Version: testVersion,
|
Version: testVersion,
|
||||||
LookupEnv: func(name string) (string, bool) {
|
LookupEnv: func(name string) (string, bool) {
|
||||||
value, ok := env[name]
|
value, ok := env[name]
|
||||||
@@ -52,7 +52,7 @@ func runRProxy(ctx context.Context, env map[string]string, out *output) int {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// startRProxy runs rproxy with servewaf at backend and the app at appURL, and
|
// startRProxy runs rproxy with discriminator at backend and the app at appURL, and
|
||||||
// returns its URL and what it writes. It believes X-Forwarded-For from the
|
// returns its URL and what it writes. It believes X-Forwarded-For from the
|
||||||
// tests. It stops as the test ends, and that it stopped in order is checked.
|
// tests. It stops as the test ends, and that it stopped in order is checked.
|
||||||
func startRProxy(t *testing.T, backend, appURL string) (string, *output) {
|
func startRProxy(t *testing.T, backend, appURL string) (string, *output) {
|
||||||
@@ -91,10 +91,10 @@ func startRProxy(t *testing.T, backend, appURL string) (string, *output) {
|
|||||||
return "http://" + addr, out
|
return "http://" + addr, out
|
||||||
}
|
}
|
||||||
|
|
||||||
// startServeWAF runs servewaf with the settings in env, and returns its address
|
// startDiscriminator runs discriminator with the settings in env, and returns
|
||||||
// and URL, what it writes, and what stops it, which checks that it stopped in
|
// its address and URL, what it writes, and what stops it, which checks that it
|
||||||
// order.
|
// stopped in order.
|
||||||
func startServeWAF(t *testing.T, env map[string]string) (string, *output, func()) {
|
func startDiscriminator(t *testing.T, env map[string]string) (string, *output, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ctx, stop := context.WithCancel(t.Context())
|
ctx, stop := context.WithCancel(t.Context())
|
||||||
@@ -113,17 +113,17 @@ func startServeWAF(t *testing.T, env map[string]string) (string, *output, func()
|
|||||||
select {
|
select {
|
||||||
case status := <-exited:
|
case status := <-exited:
|
||||||
if status != 0 {
|
if status != 0 {
|
||||||
t.Fatalf("servewaf's exit status %d, want 0; output:\n%s", status, out.text())
|
t.Fatalf("discriminator's exit status %d, want 0; output:\n%s", status, out.text())
|
||||||
}
|
}
|
||||||
case <-time.After(waitLimit):
|
case <-time.After(waitLimit):
|
||||||
t.Fatal("servewaf still running after being told to stop")
|
t.Fatal("discriminator still running after being told to stop")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveWAFSettings are servewaf's settings with the default rule file, to listen
|
// discriminatorSettings are discriminator's settings with the default rule
|
||||||
// at any free port.
|
// file, to listen at any free port.
|
||||||
func serveWAFSettings(t *testing.T) map[string]string {
|
func discriminatorSettings(t *testing.T) map[string]string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
@@ -136,7 +136,7 @@ func serveWAFSettings(t *testing.T) map[string]string {
|
|||||||
func TestABanThroughOneSiteRefusesTheClientOnTheOther(t *testing.T) {
|
func TestABanThroughOneSiteRefusesTheClientOnTheOther(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t))
|
addr, discriminatorOut, stop := startDiscriminator(t, discriminatorSettings(t))
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
backend := "http://" + addr
|
backend := "http://" + addr
|
||||||
@@ -151,30 +151,32 @@ func TestABanThroughOneSiteRefusesTheClientOnTheOther(t *testing.T) {
|
|||||||
wantStatus(t, second+"/", placed, http.StatusForbidden)
|
wantStatus(t, second+"/", placed, http.StatusForbidden)
|
||||||
wantStatus(t, second+"/", otherClient, http.StatusOK)
|
wantStatus(t, second+"/", otherClient, http.StatusOK)
|
||||||
|
|
||||||
// One request's three lines carry one id, rproxy's with servewaf's answer.
|
// One request's three lines carry one id, rproxy's with discriminator's answer.
|
||||||
line := firstOut.line(t, "path", bannedProbe)
|
line := firstOut.line(t, "path", bannedProbe)
|
||||||
if line["disposition"] != servewaf.DispositionDeny || line["client_ip"] != placed {
|
if line["disposition"] != discriminator.DispositionDeny ||
|
||||||
|
line["client_ip"] != placed {
|
||||||
t.Errorf("rproxy's line is %v", line)
|
t.Errorf("rproxy's line is %v", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
id, _ := line["request_id"].(string)
|
id, _ := line["request_id"].(string)
|
||||||
decision := serveWAFLine(t, serveWAFOut, id, "decision")
|
decision := discriminatorLine(t, discriminatorOut, id, "decision")
|
||||||
report := serveWAFLine(t, serveWAFOut, id, "report")
|
report := discriminatorLine(t, discriminatorOut, id, "report")
|
||||||
|
|
||||||
if decision["host"] != line["host"] || decision["client_ip"] != placed ||
|
if decision["host"] != line["host"] || decision["client_ip"] != placed ||
|
||||||
report["status"] != float64(http.StatusForbidden) {
|
report["status"] != float64(http.StatusForbidden) {
|
||||||
t.Errorf("servewaf's lines are %v and %v", decision, report)
|
t.Errorf("discriminator's lines are %v and %v", decision, report)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveWAFLine waits for servewaf's line with request id and servewaf_line kind.
|
// discriminatorLine waits for discriminator's line with request id and
|
||||||
func serveWAFLine(t *testing.T, out *output, id, kind string) map[string]any {
|
// discriminator_line kind.
|
||||||
|
func discriminatorLine(t *testing.T, out *output, id, kind string) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
deadline := time.Now().Add(waitLimit)
|
deadline := time.Now().Add(waitLimit)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
line := out.findLine(t, func(fields map[string]any) bool {
|
line := out.findLine(t, func(fields map[string]any) bool {
|
||||||
return fields["request_id"] == id && fields["servewaf_line"] == kind
|
return fields["request_id"] == id && fields["discriminator_line"] == kind
|
||||||
})
|
})
|
||||||
if line != nil {
|
if line != nil {
|
||||||
return line
|
return line
|
||||||
@@ -188,11 +190,11 @@ func serveWAFLine(t *testing.T, out *output, id, kind string) map[string]any {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTheSiteAnswersWhileServeWAFIsStoppedAndStartedAgain(t *testing.T) {
|
func TestTheSiteAnswersWhileDiscriminatorIsStoppedAndStartedAgain(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
env := serveWAFSettings(t)
|
env := discriminatorSettings(t)
|
||||||
addr, _, stop := startServeWAF(t, env)
|
addr, _, stop := startDiscriminator(t, env)
|
||||||
site, out := startRProxy(t, "http://"+addr, startApp(t))
|
site, out := startRProxy(t, "http://"+addr, startApp(t))
|
||||||
|
|
||||||
// A visitor who keeps asking throughout, and is never refused.
|
// A visitor who keeps asking throughout, and is never refused.
|
||||||
@@ -221,11 +223,11 @@ func TestTheSiteAnswersWhileServeWAFIsStoppedAndStartedAgain(t *testing.T) {
|
|||||||
wantStatus(t, site+"/", placed, http.StatusForbidden)
|
wantStatus(t, site+"/", placed, http.StatusForbidden)
|
||||||
stop()
|
stop()
|
||||||
|
|
||||||
// With servewaf down, the site answers, and rproxy says the default did it.
|
// With discriminator down, the site answers, and rproxy says the default did it.
|
||||||
wantStatus(t, site+"/after-stop", placed, http.StatusOK)
|
wantStatus(t, site+"/after-stop", placed, http.StatusOK)
|
||||||
|
|
||||||
line := out.line(t, "path", "/after-stop")
|
line := out.line(t, "path", "/after-stop")
|
||||||
if line["disposition"] != servewaf.DispositionAllow ||
|
if line["disposition"] != discriminator.DispositionAllow ||
|
||||||
line["default_disposition"] != true {
|
line["default_disposition"] != true {
|
||||||
t.Errorf("rproxy's line is %v", line)
|
t.Errorf("rproxy's line is %v", line)
|
||||||
}
|
}
|
||||||
@@ -233,7 +235,7 @@ func TestTheSiteAnswersWhileServeWAFIsStoppedAndStartedAgain(t *testing.T) {
|
|||||||
// Back at its address, with its state files, it applies the ban again.
|
// Back at its address, with its state files, it applies the ban again.
|
||||||
restarted := maps.Clone(env)
|
restarted := maps.Clone(env)
|
||||||
restarted[listenAddr] = addr
|
restarted[listenAddr] = addr
|
||||||
_, _, stop = startServeWAF(t, restarted)
|
_, _, stop = startDiscriminator(t, restarted)
|
||||||
|
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
@@ -306,10 +308,10 @@ func sendRaw(t *testing.T, site, request string) int {
|
|||||||
return res.StatusCode
|
return res.StatusCode
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAChunkedBodyStaysABodyAtServeWAF(t *testing.T) {
|
func TestAChunkedBodyStaysABodyAtDiscriminator(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t))
|
addr, discriminatorOut, stop := startDiscriminator(t, discriminatorSettings(t))
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
site, _ := startRProxy(t, "http://"+addr, startApp(t))
|
site, _ := startRProxy(t, "http://"+addr, startApp(t))
|
||||||
@@ -318,7 +320,7 @@ func TestAChunkedBodyStaysABodyAtServeWAF(t *testing.T) {
|
|||||||
const smuggledID = "smuggled-request"
|
const smuggledID = "smuggled-request"
|
||||||
|
|
||||||
smuggled := "GET " + bannedProbe + " HTTP/1.1\r\nHost: site.example\r\n" +
|
smuggled := "GET " + bannedProbe + " HTTP/1.1\r\nHost: site.example\r\n" +
|
||||||
servewaf.ClientHeader + ": " + otherClient + "\r\n" +
|
discriminator.ClientHeader + ": " + otherClient + "\r\n" +
|
||||||
"X-Request-ID: " + smuggledID + "\r\n\r\n"
|
"X-Request-ID: " + smuggledID + "\r\n\r\n"
|
||||||
|
|
||||||
status := sendRaw(t, site, "POST /upload HTTP/1.1\r\nHost: site.example\r\n"+
|
status := sendRaw(t, site, "POST /upload HTTP/1.1\r\nHost: site.example\r\n"+
|
||||||
@@ -334,18 +336,18 @@ func TestAChunkedBodyStaysABodyAtServeWAF(t *testing.T) {
|
|||||||
time.Sleep(pollInterval)
|
time.Sleep(pollInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
line := serveWAFOut.findLine(t, func(fields map[string]any) bool {
|
line := discriminatorOut.findLine(t, func(fields map[string]any) bool {
|
||||||
return fields["request_id"] == smuggledID
|
return fields["request_id"] == smuggledID
|
||||||
})
|
})
|
||||||
if line != nil {
|
if line != nil {
|
||||||
t.Errorf("servewaf read the body as a request: %v", line)
|
t.Errorf("discriminator read the body as a request: %v", line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestATargetThatCannotGoToServeWAFIsRefusedForABannedClient(t *testing.T) {
|
func TestATargetThatCannotGoToDiscriminatorIsRefusedForABannedClient(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
addr, _, stop := startServeWAF(t, serveWAFSettings(t))
|
addr, _, stop := startDiscriminator(t, discriminatorSettings(t))
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
site, out := startRProxy(t, "http://"+addr, startApp(t))
|
site, out := startRProxy(t, "http://"+addr, startApp(t))
|
||||||
@@ -369,7 +371,7 @@ func TestAClientWithAZoneIsTheClientWithout(t *testing.T) {
|
|||||||
|
|
||||||
const zoned = "2001:db8::1%eth0"
|
const zoned = "2001:db8::1%eth0"
|
||||||
|
|
||||||
addr, _, stop := startServeWAF(t, serveWAFSettings(t))
|
addr, _, stop := startDiscriminator(t, discriminatorSettings(t))
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
site, out := startRProxy(t, "http://"+addr, startApp(t))
|
site, out := startRProxy(t, "http://"+addr, startApp(t))
|
||||||
@@ -384,7 +386,7 @@ func TestAClientWithAZoneIsTheClientWithout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAStatusServeWAFDoesNotTakeIsReportedAs599(t *testing.T) {
|
func TestAStatusDiscriminatorDoesNotTakeIsReportedAs599(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const status = 799
|
const status = 799
|
||||||
@@ -395,7 +397,7 @@ func TestAStatusServeWAFDoesNotTakeIsReportedAs599(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
t.Cleanup(app.Close)
|
t.Cleanup(app.Close)
|
||||||
|
|
||||||
addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t))
|
addr, discriminatorOut, stop := startDiscriminator(t, discriminatorSettings(t))
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
site, out := startRProxy(t, "http://"+addr, app.URL)
|
site, out := startRProxy(t, "http://"+addr, app.URL)
|
||||||
@@ -409,13 +411,13 @@ func TestAStatusServeWAFDoesNotTakeIsReportedAs599(t *testing.T) {
|
|||||||
|
|
||||||
id, _ := line["request_id"].(string)
|
id, _ := line["request_id"].(string)
|
||||||
|
|
||||||
report := serveWAFLine(t, serveWAFOut, id, "report")
|
report := discriminatorLine(t, discriminatorOut, id, "report")
|
||||||
if report["status"] != 599.0 {
|
if report["status"] != 599.0 {
|
||||||
t.Errorf("servewaf's line is %v, want status 599", report)
|
t.Errorf("discriminator's line is %v, want status 599", report)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startRProxyProcess runs `smallwebwaf rproxy` in a process of its own, with the
|
// startRProxyProcess runs `sww rproxy` in a process of its own, with the
|
||||||
// settings in env, each as NAME=value. It returns where rproxy listens, the
|
// settings in env, each as NAME=value. It returns where rproxy listens, the
|
||||||
// process, and what rproxy writes.
|
// process, and what rproxy writes.
|
||||||
func startRProxyProcess(t *testing.T, env ...string) (string, *exec.Cmd, *output) {
|
func startRProxyProcess(t *testing.T, env ...string) (string, *exec.Cmd, *output) {
|
||||||
@@ -453,16 +455,16 @@ func startRProxyProcess(t *testing.T, env ...string) (string, *exec.Cmd, *output
|
|||||||
return addr, cmd, out
|
return addr, cmd, out
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRProxyAsksAServeWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) {
|
func TestRProxyAsksADiscriminatorThatSpeaksHTTP2OverHTTP1(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
protocols := make(chan string, 10)
|
protocols := make(chan string, 10)
|
||||||
backend := httptest.NewUnstartedServer(http.HandlerFunc(
|
backend := httptest.NewUnstartedServer(http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Header.Get(servewaf.ClientHeader) != "" {
|
if r.Header.Get(discriminator.ClientHeader) != "" {
|
||||||
protocols <- r.Proto
|
protocols <- r.Proto
|
||||||
|
|
||||||
w.Header().Set(servewaf.DispositionHeader, servewaf.DispositionDeny)
|
w.Header().Set(discriminator.DispositionHeader, discriminator.DispositionDeny)
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
@@ -472,7 +474,7 @@ func TestRProxyAsksAServeWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) {
|
|||||||
|
|
||||||
// rproxy has no setting for the certificates it trusts, so it is told of this
|
// rproxy has no setting for the certificates it trusts, so it is told of this
|
||||||
// one as Go's client is told of any, in a process of its own.
|
// one as Go's client is told of any, in a process of its own.
|
||||||
certificates := filepath.Join(t.TempDir(), "servewaf.pem")
|
certificates := filepath.Join(t.TempDir(), "discriminator.pem")
|
||||||
|
|
||||||
err := os.WriteFile(certificates, pem.EncodeToMemory(&pem.Block{
|
err := os.WriteFile(certificates, pem.EncodeToMemory(&pem.Block{
|
||||||
Type: "CERTIFICATE", Bytes: backend.Certificate().Raw,
|
Type: "CERTIFICATE", Bytes: backend.Certificate().Raw,
|
||||||
@@ -494,7 +496,7 @@ func TestRProxyAsksAServeWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
status := sendRaw(t, addr, "GET / HTTP/1.1\r\nHost: site.example\r\n"+headers+"\r\n")
|
status := sendRaw(t, addr, "GET / HTTP/1.1\r\nHost: site.example\r\n"+headers+"\r\n")
|
||||||
if status != http.StatusForbidden {
|
if status != http.StatusForbidden {
|
||||||
t.Errorf("with %s: status %d, want servewaf's 403", name, status)
|
t.Errorf("with %s: status %d, want discriminator's 403", name, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@@ -503,7 +505,7 @@ func TestRProxyAsksAServeWAFThatSpeaksHTTP2OverHTTP1(t *testing.T) {
|
|||||||
t.Errorf("with %s: asked over %s, want HTTP/1.1", name, protocol)
|
t.Errorf("with %s: asked over %s, want HTTP/1.1", name, protocol)
|
||||||
}
|
}
|
||||||
case <-time.After(waitLimit):
|
case <-time.After(waitLimit):
|
||||||
t.Errorf("with %s: servewaf was not asked", name)
|
t.Errorf("with %s: discriminator was not asked", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -532,8 +534,8 @@ func TestRProxyStoppingWaitsForAReportBeingSent(t *testing.T) {
|
|||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
backend := httptest.NewServer(http.HandlerFunc(
|
backend := httptest.NewServer(http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Header.Get(servewaf.ClientHeader) != "" {
|
if r.Header.Get(discriminator.ClientHeader) != "" {
|
||||||
w.Header().Set(servewaf.DispositionHeader, servewaf.DispositionAllow)
|
w.Header().Set(discriminator.DispositionHeader, discriminator.DispositionAllow)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -640,7 +642,7 @@ func TestRProxyStoppingCutsOffARequestAfterFiveSeconds(t *testing.T) {
|
|||||||
|
|
||||||
backend := httptest.NewServer(http.HandlerFunc(
|
backend := httptest.NewServer(http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set(servewaf.DispositionHeader, servewaf.DispositionAllow)
|
w.Header().Set(discriminator.DispositionHeader, discriminator.DispositionAllow)
|
||||||
}))
|
}))
|
||||||
t.Cleanup(backend.Close)
|
t.Cleanup(backend.Close)
|
||||||
|
|
||||||
@@ -703,7 +705,7 @@ func TestRProxyInvalidSettingStopsTheStart(t *testing.T) {
|
|||||||
{"no backend", wafBackend, nil},
|
{"no backend", wafBackend, nil},
|
||||||
{
|
{
|
||||||
"another invalid", rproxyTimeout,
|
"another invalid", rproxyTimeout,
|
||||||
map[string]string{wafBackend: "http://servewaf:8080", rproxyTimeout: "soon"},
|
map[string]string{wafBackend: "http://discriminator:8080", rproxyTimeout: "soon"},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -740,7 +742,7 @@ func TestRProxyAddressInUseStopsTheStart(t *testing.T) {
|
|||||||
out := &output{}
|
out := &output{}
|
||||||
|
|
||||||
status := runRProxy(t.Context(), map[string]string{
|
status := runRProxy(t.Context(), map[string]string{
|
||||||
wafBackend: "http://servewaf:8080",
|
wafBackend: "http://discriminator:8080",
|
||||||
rproxyListenAddr: taken.Addr().String(),
|
rproxyListenAddr: taken.Addr().String(),
|
||||||
}, out)
|
}, out)
|
||||||
if status != 1 {
|
if status != 1 {
|
||||||
@@ -757,7 +759,7 @@ func TestMainRunsRProxy(t *testing.T) {
|
|||||||
addr, cmd, out := startRProxyProcess(t, wafBackend+"=http://127.0.0.1:9")
|
addr, cmd, out := startRProxyProcess(t, wafBackend+"=http://127.0.0.1:9")
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
|
||||||
"http://"+addr+servewaf.HealthPath, http.NoBody)
|
"http://"+addr+discriminator.HealthPath, http.NoBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new request: %v", err)
|
t.Fatalf("new request: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
// Package smallwebwaf runs the smallwebwaf process, as the command it is given.
|
// Package sww runs the sww process, as the command it is given.
|
||||||
// `smallwebwaf servewaf` reads the settings, the rule files, the lookup database
|
// `sww discriminator` reads the settings, the rule files, the lookup database
|
||||||
// and the state files, answers rproxy's questions until it is told to stop, and
|
// and the state files, answers rproxy's questions until it is told to stop, and
|
||||||
// then stops in an orderly way, writing the state files. `smallwebwaf rproxy`
|
// then stops in an orderly way, writing the state files. `sww rproxy`
|
||||||
// reads rproxy's settings alone, and runs the reverse proxy in front of one app
|
// reads rproxy's settings alone, and runs the reverse proxy in front of one app
|
||||||
// that asks servewaf about each request. `smallwebwaf healthcheck` is the
|
// that asks discriminator about each request. `sww healthcheck` is the
|
||||||
// container's health check.
|
// container's health check.
|
||||||
package smallwebwaf
|
package sww
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -22,39 +22,40 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/state"
|
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// shutdownTimeout is how long requests in progress may take to finish
|
// shutdownTimeout is how long requests in progress may take to finish
|
||||||
// once smallwebwaf is told to stop, before their connections are closed.
|
// once sww is told to stop, before their connections are closed.
|
||||||
// runit and docker wait a little longer before they kill the process.
|
// runit and docker wait a little longer before they kill the process.
|
||||||
const shutdownTimeout = 5 * time.Second
|
const shutdownTimeout = 5 * time.Second
|
||||||
|
|
||||||
// remoteLogStopTimeout is how long, as servewaf stops, the log lines still
|
// remoteLogStopTimeout is how long, as discriminator stops, the log lines still
|
||||||
// waiting are sent to SWWAF_LOG_REMOTE_URL before they are given up. stdout has
|
// waiting are sent to SWW_LOG_REMOTE_URL before they are given up. stdout has
|
||||||
// carried them.
|
// carried them.
|
||||||
const remoteLogStopTimeout = 2 * time.Second
|
const remoteLogStopTimeout = 2 * time.Second
|
||||||
|
|
||||||
// usageStatus is the exit status with no command, or one smallwebwaf does not
|
// usageStatus is the exit status with no command, or one sww does not
|
||||||
// know.
|
// know.
|
||||||
const usageStatus = 2
|
const usageStatus = 2
|
||||||
|
|
||||||
// usage is what smallwebwaf prints then.
|
// usage is what sww prints then.
|
||||||
const usage = `usage: smallwebwaf <command>
|
const usage = `usage: sww <command>
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
rproxy the reverse proxy in front of one app
|
rproxy the reverse proxy in front of one app
|
||||||
servewaf decides for rproxy, and keeps bans, counters and history
|
discriminator decides for rproxy, and keeps bans, counters and history
|
||||||
healthcheck the container's health check
|
healthcheck the container's health check
|
||||||
`
|
`
|
||||||
|
|
||||||
// Params are what RunServeWAF and RunRProxy need from the process.
|
// Params are what RunDiscriminator and RunRProxy need from the process.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// Version is the version of the binary, set when it is built.
|
// Version is the version of the binary, set when it is built.
|
||||||
Version string
|
Version string
|
||||||
@@ -65,7 +66,7 @@ type Params struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Main runs the command os.Args names, and returns the process's exit status.
|
// Main runs the command os.Args names, and returns the process's exit status.
|
||||||
// `smallwebwaf rproxy` and `smallwebwaf servewaf` run until SIGTERM or SIGINT.
|
// `sww rproxy` and `sww discriminator` run until SIGTERM or SIGINT.
|
||||||
// With no command, or one it does not know, it prints the commands and returns
|
// With no command, or one it does not know, it prints the commands and returns
|
||||||
// usageStatus.
|
// usageStatus.
|
||||||
func Main(version string) int {
|
func Main(version string) int {
|
||||||
@@ -83,8 +84,8 @@ func Main(version string) int {
|
|||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "rproxy":
|
case "rproxy":
|
||||||
return RunRProxy(ctx, params)
|
return RunRProxy(ctx, params)
|
||||||
case "servewaf":
|
case "discriminator":
|
||||||
return RunServeWAF(ctx, params)
|
return RunDiscriminator(ctx, params)
|
||||||
case "healthcheck":
|
case "healthcheck":
|
||||||
return HealthCheck(ctx, args[1:], os.LookupEnv, os.Stderr)
|
return HealthCheck(ctx, args[1:], os.LookupEnv, os.Stderr)
|
||||||
default:
|
default:
|
||||||
@@ -99,12 +100,12 @@ func printUsage(w io.Writer) int {
|
|||||||
return usageStatus
|
return usageStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunServeWAF is `smallwebwaf servewaf`: it reads the settings, the rule files,
|
// RunDiscriminator is `sww discriminator`: it reads the settings, the rule files,
|
||||||
// the lookup database and the state files, then serves requests until ctx is
|
// the lookup database and the state files, then serves requests until ctx is
|
||||||
// done. It returns the process's exit status, 1 when servewaf cannot start.
|
// done. It returns the process's exit status, 1 when discriminator cannot start.
|
||||||
func RunServeWAF(ctx context.Context, params Params) int {
|
func RunDiscriminator(ctx context.Context, params Params) int {
|
||||||
// Until the settings are read, the one message is an invalid setting's
|
// Until the settings are read, the one message is an invalid setting's
|
||||||
// error, which every SWWAF_LOG_LEVEL lets through.
|
// error, which every SWW_LOG_LEVEL lets through.
|
||||||
processLog := requestlog.NewProcessLogger(params.Stdout,
|
processLog := requestlog.NewProcessLogger(params.Stdout,
|
||||||
config.InstanceName(params.LookupEnv), slog.LevelError)
|
config.InstanceName(params.LookupEnv), slog.LevelError)
|
||||||
|
|
||||||
@@ -115,7 +116,7 @@ func RunServeWAF(ctx context.Context, params Params) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// While SWWAF_LOG_REMOTE_URL is set, every line on stdout from here on
|
// While SWW_LOG_REMOTE_URL is set, every line on stdout from here on
|
||||||
// is sent there too.
|
// is sent there too.
|
||||||
stdout := params.Stdout
|
stdout := params.Stdout
|
||||||
|
|
||||||
@@ -150,7 +151,14 @@ func RunServeWAF(ctx context.Context, params Params) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
server, err := newServer(cfg, stdout, processLog, now, ruleFiles, alertQueue)
|
crs, err := discriminator.NewCoreRuleSet(cfg)
|
||||||
|
if err != nil {
|
||||||
|
processLog.Error("cannot use the Core Rule Set", "error", err.Error())
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := newServer(cfg, stdout, processLog, now, ruleFiles, alertQueue, crs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
processLog.Error("cannot use the lookup database", "error", err.Error())
|
processLog.Error("cannot use the lookup database", "error", err.Error())
|
||||||
|
|
||||||
@@ -170,8 +178,7 @@ func RunServeWAF(ctx context.Context, params Params) int {
|
|||||||
|
|
||||||
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr)
|
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
processLog.Error("cannot listen on SWWAF_LISTEN_ADDR",
|
processLog.Error("cannot listen on SWW_LISTEN_ADDR", "error", err.Error())
|
||||||
"error", err.Error())
|
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
@@ -184,13 +191,14 @@ func RunServeWAF(ctx context.Context, params Params) int {
|
|||||||
return serve(ctx, server, listener, files, ruleFiles, alertQueue, processLog)
|
return serve(ctx, server, listener, files, ruleFiles, alertQueue, processLog)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newServer returns the server servewaf runs, with the metrics of the alerts,
|
// newServer returns the server discriminator runs, with the metrics of the alerts,
|
||||||
// after reading the lookup database while SWWAF_LOOKUP_SOURCE is file. A lookup
|
// after reading the lookup database while SWW_LOOKUP_SOURCE is file. A lookup
|
||||||
// database that cannot be read is an error.
|
// database that cannot be read is an error.
|
||||||
func newServer(
|
func newServer(
|
||||||
cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
|
cfg *config.Config, stdout io.Writer, processLog *slog.Logger,
|
||||||
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
|
now func() time.Time, ruleFiles *rules.Files, alertQueue *alerts.Queue,
|
||||||
) (*servewaf.Server, error) {
|
crs *waf.CoreRuleSet,
|
||||||
|
) (*discriminator.Server, error) {
|
||||||
var lookupFile *lookup.File
|
var lookupFile *lookup.File
|
||||||
|
|
||||||
if cfg.LookupSource == "file" {
|
if cfg.LookupSource == "file" {
|
||||||
@@ -207,13 +215,14 @@ func newServer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
server := servewaf.New(servewaf.Params{
|
server := discriminator.New(discriminator.Params{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
RequestLog: stdout,
|
RequestLog: stdout,
|
||||||
ProcessLog: processLog,
|
ProcessLog: processLog,
|
||||||
GeoJSURL: lookup.URL,
|
GeoJSURL: lookup.URL,
|
||||||
AbuseIPDBURL: reputation.AbuseIPDBURL,
|
AbuseIPDBURL: reputation.AbuseIPDBURL,
|
||||||
LookupFile: lookupFile,
|
LookupFile: lookupFile,
|
||||||
|
CoreRuleSet: crs,
|
||||||
Now: now,
|
Now: now,
|
||||||
Rules: ruleFiles,
|
Rules: ruleFiles,
|
||||||
Alerts: alertQueue,
|
Alerts: alertQueue,
|
||||||
@@ -230,7 +239,7 @@ func newServer(
|
|||||||
// loadStateFiles reads the state files into the parts of server and into
|
// loadStateFiles reads the state files into the parts of server and into
|
||||||
// alertQueue, as state.Load does.
|
// alertQueue, as state.Load does.
|
||||||
func loadStateFiles(
|
func loadStateFiles(
|
||||||
cfg *config.Config, server *servewaf.Server, alertQueue *alerts.Queue,
|
cfg *config.Config, server *discriminator.Server, alertQueue *alerts.Queue,
|
||||||
now func() time.Time, processLog *slog.Logger,
|
now func() time.Time, processLog *slog.Logger,
|
||||||
) (*state.Files, error) {
|
) (*state.Files, error) {
|
||||||
return state.Load(state.Params{
|
return state.Load(state.Params{
|
||||||
@@ -272,7 +281,7 @@ func newAlertQueue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newRemoteLogSender returns a sender of the log lines to
|
// newRemoteLogSender returns a sender of the log lines to
|
||||||
// SWWAF_LOG_REMOTE_URL, with the settings for it.
|
// SWW_LOG_REMOTE_URL, with the settings for it.
|
||||||
func newRemoteLogSender(cfg *config.Config) *remotelog.Sender {
|
func newRemoteLogSender(cfg *config.Config) *remotelog.Sender {
|
||||||
return remotelog.New(remotelog.Params{
|
return remotelog.New(remotelog.Params{
|
||||||
URL: cfg.LogRemoteURL,
|
URL: cfg.LogRemoteURL,
|
||||||
@@ -286,7 +295,7 @@ func newRemoteLogSender(cfg *config.Config) *remotelog.Sender {
|
|||||||
// startSending runs remote until the function it returns is called, which
|
// startSending runs remote until the function it returns is called, which
|
||||||
// then waits at most remoteLogStopTimeout for the lines still waiting to
|
// then waits at most remoteLogStopTimeout for the lines still waiting to
|
||||||
// be sent. Sending goes on after ctx is done, so that the lines written
|
// be sent. Sending goes on after ctx is done, so that the lines written
|
||||||
// while smallwebwaf stops are sent too.
|
// while sww stops are sent too.
|
||||||
func startSending(
|
func startSending(
|
||||||
ctx context.Context, remote *remotelog.Sender, processLog *slog.Logger,
|
ctx context.Context, remote *remotelog.Sender, processLog *slog.Logger,
|
||||||
) func() {
|
) func() {
|
||||||
@@ -316,7 +325,7 @@ func startSending(
|
|||||||
// shutdownTimeout to finish, and writes every state file, alerts.json with
|
// shutdownTimeout to finish, and writes every state file, alerts.json with
|
||||||
// the alerts still waiting.
|
// the alerts still waiting.
|
||||||
func serve(
|
func serve(
|
||||||
ctx context.Context, server *servewaf.Server, listener net.Listener,
|
ctx context.Context, server *discriminator.Server, listener net.Listener,
|
||||||
files *state.Files, ruleFiles *rules.Files, alertQueue *alerts.Queue,
|
files *state.Files, ruleFiles *rules.Files, alertQueue *alerts.Queue,
|
||||||
processLog *slog.Logger,
|
processLog *slog.Logger,
|
||||||
) int {
|
) int {
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package smallwebwaf
|
package sww
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package smallwebwaf_test
|
package sww_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -19,9 +19,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
"sneak.berlin/go/smallwebwaf/internal/sww"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -29,25 +30,25 @@ const (
|
|||||||
waitLimit = 10 * time.Second
|
waitLimit = 10 * time.Second
|
||||||
// pollInterval is how often a test looks for a line.
|
// pollInterval is how often a test looks for a line.
|
||||||
pollInterval = 10 * time.Millisecond
|
pollInterval = 10 * time.Millisecond
|
||||||
// testVersion is the version the tests give smallwebwaf.
|
// testVersion is the version the tests give sww.
|
||||||
testVersion = "test"
|
testVersion = "test"
|
||||||
// localhost is where the tests listen.
|
// localhost is where the tests listen.
|
||||||
localhost = "127.0.0.1"
|
localhost = "127.0.0.1"
|
||||||
listenAddr = "SWWAF_LISTEN_ADDR"
|
listenAddr = "SWW_LISTEN_ADDR"
|
||||||
stateDir = "SWWAF_STATE_DIR"
|
stateDir = "SWW_STATE_DIR"
|
||||||
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY"
|
stateWriteDelay = "SWW_STATE_WRITE_DELAY"
|
||||||
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
|
stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL"
|
||||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
|
||||||
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
|
||||||
rulesDir = "SWWAF_RULES_DIR"
|
rulesDir = "SWW_RULES_DIR"
|
||||||
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
lookupSource = "SWW_LOOKUP_SOURCE"
|
||||||
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
|
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
||||||
instanceName = "SWWAF_INSTANCE_NAME"
|
instanceName = "SWW_INSTANCE_NAME"
|
||||||
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||||
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||||
// adminSecret is the SWWAF_ADMIN_TOKEN the tests set.
|
// adminSecret is the SWW_ADMIN_TOKEN the tests set.
|
||||||
adminSecret = "fedcba9876543210fedcba9876543210"
|
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||||
// instance is the SWWAF_INSTANCE_NAME the tests set where they look at
|
// instance is the SWW_INSTANCE_NAME the tests set where they look at
|
||||||
// it.
|
// it.
|
||||||
instance = "fsn1app1/blog"
|
instance = "fsn1app1/blog"
|
||||||
// greeting is what the tests' app answers.
|
// greeting is what the tests' app answers.
|
||||||
@@ -56,13 +57,13 @@ const (
|
|||||||
placed = "203.0.113.9"
|
placed = "203.0.113.9"
|
||||||
)
|
)
|
||||||
|
|
||||||
// output collects what smallwebwaf writes on stdout.
|
// output collects what sww writes on stdout.
|
||||||
type output struct {
|
type output struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
buf bytes.Buffer
|
buf bytes.Buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write adds lines smallwebwaf writes.
|
// Write adds lines sww writes.
|
||||||
func (o *output) Write(p []byte) (int, error) {
|
func (o *output) Write(p []byte) (int, error) {
|
||||||
o.mu.Lock()
|
o.mu.Lock()
|
||||||
defer o.mu.Unlock()
|
defer o.mu.Unlock()
|
||||||
@@ -122,15 +123,15 @@ func (o *output) text() string {
|
|||||||
return o.buf.String()
|
return o.buf.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// run runs `smallwebwaf servewaf` with the settings in env until ctx is done,
|
// run runs `sww discriminator` with the settings in env until ctx is done,
|
||||||
// and returns its exit status. SWWAF_LOOKUP_SOURCE is off unless env sets it,
|
// and returns its exit status. SWW_LOOKUP_SOURCE is off unless env sets it,
|
||||||
// so that no test sends GeoJS its clients' addresses.
|
// so that no test sends GeoJS its clients' addresses.
|
||||||
func run(ctx context.Context, env map[string]string, out *output) int {
|
func run(ctx context.Context, env map[string]string, out *output) int {
|
||||||
return smallwebwaf.RunServeWAF(ctx, smallwebwaf.Params{
|
return sww.RunDiscriminator(ctx, sww.Params{
|
||||||
Version: testVersion,
|
Version: testVersion,
|
||||||
LookupEnv: func(name string) (string, bool) {
|
LookupEnv: func(name string) (string, bool) {
|
||||||
value, ok := env[name]
|
value, ok := env[name]
|
||||||
if !ok && name == "SWWAF_LOOKUP_SOURCE" {
|
if !ok && name == "SWW_LOOKUP_SOURCE" {
|
||||||
return "off", true
|
return "off", true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +147,7 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
|
|||||||
out := &output{}
|
out := &output{}
|
||||||
|
|
||||||
status := run(t.Context(), map[string]string{
|
status := run(t.Context(), map[string]string{
|
||||||
"SWWAF_BYTES_LIMIT_PER_MINUTE": "lots",
|
"SWW_BYTES_LIMIT_PER_MINUTE": "lots",
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
}, out)
|
}, out)
|
||||||
if status != 1 {
|
if status != 1 {
|
||||||
@@ -158,7 +159,7 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
|
|||||||
|
|
||||||
if line["type"] != "process" || line["instance"] != instance ||
|
if line["type"] != "process" || line["instance"] != instance ||
|
||||||
line["level"] != "ERROR" ||
|
line["level"] != "ERROR" ||
|
||||||
!strings.HasPrefix(message, "SWWAF_BYTES_LIMIT_PER_MINUTE: ") {
|
!strings.HasPrefix(message, "SWW_BYTES_LIMIT_PER_MINUTE: ") {
|
||||||
t.Errorf("start refused with %v", line)
|
t.Errorf("start refused with %v", line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,7 +215,7 @@ func TestAddressInUseStopsTheStart(t *testing.T) {
|
|||||||
t.Errorf("exit status %d, want 1", status)
|
t.Errorf("exit status %d, want 1", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
out.line(t, "msg", "cannot listen on SWWAF_LISTEN_ADDR")
|
out.line(t, "msg", "cannot listen on SWW_LISTEN_ADDR")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServesUntilToldToStop(t *testing.T) {
|
func TestServesUntilToldToStop(t *testing.T) {
|
||||||
@@ -263,7 +264,7 @@ func TestServesUntilToldToStop(t *testing.T) {
|
|||||||
func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) {
|
func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// A list that cannot be fetched has a warning written once smallwebwaf
|
// A list that cannot be fetched has a warning written once sww
|
||||||
// serves, after its starting line.
|
// serves, after its starting line.
|
||||||
lists := httptest.NewServer(http.HandlerFunc(
|
lists := httptest.NewServer(http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -280,8 +281,8 @@ func TestLogLevelHoldsBackTheLessSevereProcessLines(t *testing.T) {
|
|||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
"SWWAF_BLOCKLIST_URLS": lists.URL + "/tor.txt",
|
"SWW_BLOCKLIST_URLS": lists.URL + "/tor.txt",
|
||||||
"SWWAF_LOG_LEVEL": "warn",
|
"SWW_LOG_LEVEL": "warn",
|
||||||
}, out)
|
}, out)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -324,7 +325,7 @@ func TestEveryLogLineAndMetricCarriesTheInstanceName(t *testing.T) {
|
|||||||
var metrics string
|
var metrics string
|
||||||
|
|
||||||
out := runUntilStopped(t, env, func(url string) {
|
out := runUntilStopped(t, env, func(url string) {
|
||||||
metrics = metricsText(t, url+"_smallwebwaf/metrics", token)
|
metrics = metricsText(t, url+"_sww/metrics", token)
|
||||||
})
|
})
|
||||||
|
|
||||||
// The process's lines from its start to its stop, and the request's.
|
// The process's lines from its start to its stop, and the request's.
|
||||||
@@ -356,7 +357,7 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
|
|||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
rateLimitPerDay: "2",
|
rateLimitPerDay: "2",
|
||||||
// Neither comes due in the test: the files are written as
|
// Neither comes due in the test: the files are written as
|
||||||
// smallwebwaf stops.
|
// sww stops.
|
||||||
stateWriteDelay: "1h",
|
stateWriteDelay: "1h",
|
||||||
stateCounterInterval: "1h",
|
stateCounterInterval: "1h",
|
||||||
}
|
}
|
||||||
@@ -384,7 +385,7 @@ func TestStateKeptAcrossRestarts(t *testing.T) {
|
|||||||
func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
|
func TestBanRefusesItsNetblockAfterARestartWithAnotherScope(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const scope = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
const scope = "SWW_BAN_SCOPE_V4_PREFIX"
|
||||||
|
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
@@ -461,13 +462,13 @@ func TestBanAddedAndLiftedThroughTheEndpointsKeptInBansJSON(t *testing.T) {
|
|||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
adminToken: adminSecret,
|
adminToken: adminSecret,
|
||||||
// Neither comes due in the test: the files are written as
|
// Neither comes due in the test: the files are written as
|
||||||
// smallwebwaf stops.
|
// sww stops.
|
||||||
stateWriteDelay: "1h",
|
stateWriteDelay: "1h",
|
||||||
stateCounterInterval: "1h",
|
stateCounterInterval: "1h",
|
||||||
}
|
}
|
||||||
|
|
||||||
runUntilStopped(t, env, func(url string) {
|
runUntilStopped(t, env, func(url string) {
|
||||||
askAsAdmin(t, http.MethodPost, url+"_smallwebwaf/bans",
|
askAsAdmin(t, http.MethodPost, url+"_sww/bans",
|
||||||
`{"netblock": "203.0.113.0/24", "duration": "permanent", `+
|
`{"netblock": "203.0.113.0/24", "duration": "permanent", `+
|
||||||
`"reason": "probes for logins"}`)
|
`"reason": "probes for logins"}`)
|
||||||
wantStatus(t, url, "203.0.113.9", http.StatusForbidden)
|
wantStatus(t, url, "203.0.113.9", http.StatusForbidden)
|
||||||
@@ -484,7 +485,7 @@ func TestBanAddedAndLiftedThroughTheEndpointsKeptInBansJSON(t *testing.T) {
|
|||||||
// more, and bans.json keeps it, marked lifted.
|
// more, and bans.json keeps it, marked lifted.
|
||||||
runUntilStopped(t, env, func(url string) {
|
runUntilStopped(t, env, func(url string) {
|
||||||
wantStatus(t, url, "203.0.113.9", http.StatusForbidden)
|
wantStatus(t, url, "203.0.113.9", http.StatusForbidden)
|
||||||
askAsAdmin(t, http.MethodDelete, url+"_smallwebwaf/bans/203.0.113.9", "")
|
askAsAdmin(t, http.MethodDelete, url+"_sww/bans/203.0.113.9", "")
|
||||||
wantStatus(t, url, "203.0.113.9", http.StatusOK)
|
wantStatus(t, url, "203.0.113.9", http.StatusOK)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -504,14 +505,14 @@ func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
|
|||||||
rulesDir: dir,
|
rulesDir: dir,
|
||||||
// The requests sent until the rule takes effect must not break a
|
// The requests sent until the rule takes effect must not break a
|
||||||
// rate limit, whose ban would refuse them too.
|
// rate limit, whose ban would refuse them too.
|
||||||
"SWWAF_RATE_LIMIT_PER_MINUTE": "off",
|
"SWW_RATE_LIMIT_PER_MINUTE": "off",
|
||||||
}
|
}
|
||||||
|
|
||||||
out := runUntilStopped(t, env, func(url string) {
|
out := runUntilStopped(t, env, func(url string) {
|
||||||
wantAllowed(t, url)
|
wantAllowed(t, url)
|
||||||
|
|
||||||
// Written once: each change would start the rule files' wait
|
// Written once: each change would start the rule files' wait
|
||||||
// again. A file written before smallwebwaf watches the directory is
|
// again. A file written before sww watches the directory is
|
||||||
// read once it does.
|
// read once it does.
|
||||||
err := os.WriteFile(filepath.Join(dir, "50-app.rules"),
|
err := os.WriteFile(filepath.Join(dir, "50-app.rules"),
|
||||||
[]byte("everything path block ^/\n"), 0o600)
|
[]byte("everything path block ^/\n"), 0o600)
|
||||||
@@ -547,10 +548,60 @@ func TestRulesDirThatDoesNotExistStopsTheStart(t *testing.T) {
|
|||||||
|
|
||||||
dir := filepath.Join(t.TempDir(), "rules.d")
|
dir := filepath.Join(t.TempDir(), "rules.d")
|
||||||
|
|
||||||
wantRulesRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+
|
wantRulesRefused(t, dir, "SWW_RULES_DIR cannot be read: open "+dir+
|
||||||
": no such file or directory")
|
": no such file or directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoreRuleSetExclusionsFileThatCannotBeReadStopsTheStart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "crs-exclusions.conf")
|
||||||
|
out := &output{}
|
||||||
|
|
||||||
|
status := run(t.Context(), map[string]string{
|
||||||
|
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
|
||||||
|
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE": path,
|
||||||
|
}, out)
|
||||||
|
if status != 1 {
|
||||||
|
t.Fatalf("exit status %d, want 1", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := out.line(t, "msg", "cannot use the Core Rule Set")
|
||||||
|
want := "SWW_CORE_RULE_SET_EXCLUSIONS_FILE: read the exclusions file: open " +
|
||||||
|
path + ": no such file or directory"
|
||||||
|
|
||||||
|
if line["error"] != want {
|
||||||
|
t.Errorf("start refused with %v, want the error %q", line, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoreRuleSetExclusionsFileThatCannotBeUsedStopsTheStartWhileTheCoreRuleSetIsOff(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
broken := filepath.Join(t.TempDir(), "broken.conf")
|
||||||
|
|
||||||
|
err := os.WriteFile(broken, []byte("SecNoSuchDirective On\n"), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range []string{filepath.Join(t.TempDir(), "missing.conf"), broken} {
|
||||||
|
out := &output{}
|
||||||
|
|
||||||
|
status := run(t.Context(), map[string]string{
|
||||||
|
listenAddr: localhost + ":0", stateDir: t.TempDir(), rulesDir: t.TempDir(),
|
||||||
|
"SWW_WAF_MODE": config.WAFModeOff, "SWW_CORE_RULE_SET_EXCLUSIONS_FILE": path,
|
||||||
|
}, out)
|
||||||
|
if status != 1 {
|
||||||
|
t.Fatalf("%s: exit status %d, want 1", path, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
out.line(t, "msg", "cannot use the Core Rule Set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
|
func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -567,7 +618,7 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
|
|||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
lookupSource: "file",
|
lookupSource: "file",
|
||||||
lookupDBPath: path,
|
lookupDBPath: path,
|
||||||
"SWWAF_DENIED_COUNTRIES": "kp",
|
"SWW_DENIED_COUNTRIES": "kp",
|
||||||
// The requests sent until a replacement takes effect, and those
|
// The requests sent until a replacement takes effect, and those
|
||||||
// for the metrics, must not break a rate limit, whose ban would
|
// for the metrics, must not break a rate limit, whose ban would
|
||||||
// refuse them too.
|
// refuse them too.
|
||||||
@@ -583,7 +634,7 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
|
|||||||
runUntilStopped(t, env, func(url string) {
|
runUntilStopped(t, env, func(url string) {
|
||||||
wantStatus(t, url, placed, http.StatusOK)
|
wantStatus(t, url, placed, http.StatusOK)
|
||||||
|
|
||||||
// smallwebwaf reads the file again once it watches its directory,
|
// sww reads the file again once it watches its directory,
|
||||||
// which may be after the first replacement. Once that has taken
|
// which may be after the first replacement. Once that has taken
|
||||||
// effect, only the watch can show the next. Each takes as long as
|
// effect, only the watch can show the next. Each takes as long as
|
||||||
// it takes, so that a slow test process cannot fail the test.
|
// it takes, so that a slow test process cannot fail the test.
|
||||||
@@ -609,10 +660,10 @@ func TestLookupDatabaseReplacedWhileRunningTakesEffect(t *testing.T) {
|
|||||||
|
|
||||||
rename(t, replacement, path)
|
rename(t, replacement, path)
|
||||||
|
|
||||||
metrics := metricsWith(t, url+"_smallwebwaf/metrics", token,
|
metrics := metricsWith(t, url+"_sww/metrics", token,
|
||||||
`smallwebwaf_lookup_database_read_failures_total{instance="fsn1app1/blog"} 1`)
|
`sww_lookup_database_read_failures_total{instance="fsn1app1/blog"} 1`)
|
||||||
lastRead = seriesValue(t, metrics,
|
lastRead = seriesValue(t, metrics,
|
||||||
`smallwebwaf_lookup_database_last_read_timestamp_seconds{instance="fsn1app1/blog"}`)
|
`sww_lookup_database_last_read_timestamp_seconds{instance="fsn1app1/blog"}`)
|
||||||
|
|
||||||
wantStatus(t, url, placed, http.StatusOK)
|
wantStatus(t, url, placed, http.StatusOK)
|
||||||
})
|
})
|
||||||
@@ -662,28 +713,28 @@ func TestBlocklistTriesAndCopiesKeptInReputationJSONAcrossRestarts(t *testing.T)
|
|||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
"SWWAF_BLOCKLIST_URLS": torURL,
|
"SWW_BLOCKLIST_URLS": torURL,
|
||||||
// The requests sent until the list takes effect, and those for the
|
// The requests sent until the list takes effect, and those for the
|
||||||
// metrics, must not break a rate limit, whose ban would refuse them
|
// metrics, must not break a rate limit, whose ban would refuse them
|
||||||
// too.
|
// too.
|
||||||
rateLimitExemptNets: placed + "," + localhost,
|
rateLimitExemptNets: placed + "," + localhost,
|
||||||
}
|
}
|
||||||
failures := `smallwebwaf_reputation_failures_total{instance="fsn1app1/blog",` +
|
failures := `sww_reputation_failures_total{instance="fsn1app1/blog",` +
|
||||||
`source="` + torURL + `"} `
|
`source="` + torURL + `"} `
|
||||||
|
|
||||||
// tor.txt cannot be fetched at first, which is counted.
|
// tor.txt cannot be fetched at first, which is counted.
|
||||||
failing.Store(true)
|
failing.Store(true)
|
||||||
runUntilStopped(t, env, func(url string) {
|
runUntilStopped(t, env, func(url string) {
|
||||||
metricsWith(t, url+"_smallwebwaf/metrics", token, failures+"1")
|
metricsWith(t, url+"_sww/metrics", token, failures+"1")
|
||||||
})
|
})
|
||||||
|
|
||||||
// Restarted with drop.txt named after it and the server answering, tor.txt
|
// Restarted with drop.txt named after it and the server answering, tor.txt
|
||||||
// waits SWWAF_BLOCKLIST_REFRESH after its failed try, kept in reputation.json,
|
// waits SWW_BLOCKLIST_REFRESH after its failed try, kept in reputation.json,
|
||||||
// while drop.txt, never tried, is fetched at once. Lists are fetched in the
|
// while drop.txt, never tried, is fetched at once. Lists are fetched in the
|
||||||
// order named, so once drop.txt refuses the client, tor.txt has had its turn.
|
// order named, so once drop.txt refuses the client, tor.txt has had its turn.
|
||||||
failing.Store(false)
|
failing.Store(false)
|
||||||
|
|
||||||
env["SWWAF_BLOCKLIST_URLS"] = torURL + "," + dropURL
|
env["SWW_BLOCKLIST_URLS"] = torURL + "," + dropURL
|
||||||
out := runUntilStopped(t, env, func(url string) {
|
out := runUntilStopped(t, env, func(url string) {
|
||||||
for statusFrom(t, url, placed) != http.StatusForbidden {
|
for statusFrom(t, url, placed) != http.StatusForbidden {
|
||||||
time.Sleep(pollInterval)
|
time.Sleep(pollInterval)
|
||||||
@@ -742,8 +793,8 @@ func TestCrowdSecDecisionListKeptInReputationJSONAcrossARestart(t *testing.T) {
|
|||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: dir,
|
stateDir: dir,
|
||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
"SWWAF_CROWDSEC_LAPI_URL": engine.URL,
|
"SWW_CROWDSEC_LAPI_URL": engine.URL,
|
||||||
"SWWAF_CROWDSEC_LAPI_KEY": bouncerKey,
|
"SWW_CROWDSEC_LAPI_KEY": bouncerKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Once the list is fetched, the client's request bans it.
|
// Once the list is fetched, the client's request bans it.
|
||||||
@@ -816,7 +867,7 @@ func TestLookupDatabaseThatCannotBeReadStopsTheStart(t *testing.T) {
|
|||||||
t.Fatalf("exit status %d, want 1", status)
|
t.Fatalf("exit status %d, want 1", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
want := "SWWAF_LOOKUP_DB_PATH cannot be read: open " + path +
|
want := "SWW_LOOKUP_DB_PATH cannot be read: open " + path +
|
||||||
": no such file or directory"
|
": no such file or directory"
|
||||||
|
|
||||||
line := out.line(t, "msg", "cannot use the lookup database")
|
line := out.line(t, "msg", "cannot use the lookup database")
|
||||||
@@ -841,7 +892,7 @@ func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) {
|
|||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
"SWWAF_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
|
"SWW_LOG_REMOTE_URL": "syslog+tcp://" + endpoint.Addr().String(),
|
||||||
}
|
}
|
||||||
|
|
||||||
out := runUntilStopped(t, env, func(url string) {
|
out := runUntilStopped(t, env, func(url string) {
|
||||||
@@ -849,7 +900,7 @@ func TestEveryLineIsAlsoSentToTheRemoteLogEndpoint(t *testing.T) {
|
|||||||
})
|
})
|
||||||
out.line(t, "type", "request")
|
out.line(t, "type", "request")
|
||||||
|
|
||||||
// smallwebwaf connected as it started, and closes the connection once
|
// sww connected as it started, and closes the connection once
|
||||||
// it has sent the lines written as it stopped.
|
// it has sent the lines written as it stopped.
|
||||||
conn, err := endpoint.Accept()
|
conn, err := endpoint.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -896,8 +947,8 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
|
|||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: t.TempDir(),
|
stateDir: t.TempDir(),
|
||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
"SWWAF_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(),
|
"SWW_LOG_REMOTE_URL": "syslog+tls://" + endpoint.Addr().String(),
|
||||||
"SWWAF_LOG_REMOTE_BUFFER": "1",
|
"SWW_LOG_REMOTE_BUFFER": "1",
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
}
|
}
|
||||||
@@ -907,17 +958,17 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
|
|||||||
|
|
||||||
// More than one line has been written, and the buffer holds the
|
// More than one line has been written, and the buffer holds the
|
||||||
// last.
|
// last.
|
||||||
metrics := metricsText(t, url+"_smallwebwaf/metrics", token)
|
metrics := metricsText(t, url+"_sww/metrics", token)
|
||||||
for _, series := range []string{
|
for _, series := range []string{
|
||||||
`smallwebwaf_remote_log_lines_sent_total{instance="fsn1app1/blog"} 0`,
|
`sww_remote_log_lines_sent_total{instance="fsn1app1/blog"} 0`,
|
||||||
`smallwebwaf_remote_log_buffer_depth{instance="fsn1app1/blog"} 1`,
|
`sww_remote_log_buffer_depth{instance="fsn1app1/blog"} 1`,
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(metrics, "\n"+series+"\n") {
|
if !strings.Contains(metrics, "\n"+series+"\n") {
|
||||||
t.Errorf("no %q in the metrics:\n%s", series, metrics)
|
t.Errorf("no %q in the metrics:\n%s", series, metrics)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dropped = "\nsmallwebwaf_remote_log_lines_dropped_total" +
|
const dropped = "\nsww_remote_log_lines_dropped_total" +
|
||||||
`{instance="fsn1app1/blog"} `
|
`{instance="fsn1app1/blog"} `
|
||||||
if strings.Contains(metrics, dropped+"0\n") || !strings.Contains(metrics, dropped) {
|
if strings.Contains(metrics, dropped+"0\n") || !strings.Contains(metrics, dropped) {
|
||||||
t.Errorf("no line dropped in the metrics:\n%s", metrics)
|
t.Errorf("no line dropped in the metrics:\n%s", metrics)
|
||||||
@@ -955,8 +1006,8 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
|
|||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: dir,
|
stateDir: dir,
|
||||||
rulesDir: rules,
|
rulesDir: rules,
|
||||||
"SWWAF_ALERT_WEBHOOK_URL": webhook.url,
|
"SWW_ALERT_WEBHOOK_URL": webhook.url,
|
||||||
"SWWAF_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
|
"SWW_ALERT_WEBHOOK_HEADERS": "Authorization:Bearer " + adminSecret,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -978,8 +1029,8 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
|
|||||||
webhook.waitFor(t, "permanent_ban", false)
|
webhook.waitFor(t, "permanent_ban", false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// alerts.json keeps it as smallwebwaf stops, and once started again,
|
// alerts.json keeps it as sww stops, and once started again,
|
||||||
// smallwebwaf sends it.
|
// sww sends it.
|
||||||
var file struct {
|
var file struct {
|
||||||
Waiting map[string][]struct {
|
Waiting map[string][]struct {
|
||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
@@ -1005,7 +1056,7 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
|
|||||||
|
|
||||||
webhook.failing.Store(false)
|
webhook.failing.Store(false)
|
||||||
|
|
||||||
env["SWWAF_ALLOW_NETS"] = localhost
|
env["SWW_ALLOW_NETS"] = localhost
|
||||||
env[metricsToken] = token
|
env[metricsToken] = token
|
||||||
|
|
||||||
runUntilStopped(t, env, func(url string) {
|
runUntilStopped(t, env, func(url string) {
|
||||||
@@ -1013,11 +1064,11 @@ func TestBanIsAlertedAndAnAlertNotSentIsKeptAcrossARestart(t *testing.T) {
|
|||||||
|
|
||||||
const ofWebhook = `{destination="webhook",instance="fsn1app1/blog"}`
|
const ofWebhook = `{destination="webhook",instance="fsn1app1/blog"}`
|
||||||
|
|
||||||
metrics := metricsWith(t, url+"_smallwebwaf/metrics", token,
|
metrics := metricsWith(t, url+"_sww/metrics", token,
|
||||||
"\nsmallwebwaf_alerts_sent_total"+ofWebhook+" 1\n")
|
"\nsww_alerts_sent_total"+ofWebhook+" 1\n")
|
||||||
|
|
||||||
for _, series := range []string{"failed", "suppressed", "dropped"} {
|
for _, series := range []string{"failed", "suppressed", "dropped"} {
|
||||||
zero := "\nsmallwebwaf_alerts_" + series + "_total" + ofWebhook + " 0\n"
|
zero := "\nsww_alerts_" + series + "_total" + ofWebhook + " 0\n"
|
||||||
if !strings.Contains(metrics, zero) {
|
if !strings.Contains(metrics, zero) {
|
||||||
t.Errorf("no %q in the metrics:\n%s", zero, metrics)
|
t.Errorf("no %q in the metrics:\n%s", zero, metrics)
|
||||||
}
|
}
|
||||||
@@ -1041,12 +1092,12 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
|
|||||||
rulesDir: t.TempDir(),
|
rulesDir: t.TempDir(),
|
||||||
rateLimitPerDay: "1",
|
rateLimitPerDay: "1",
|
||||||
// The metrics are read from 127.0.0.1, which no limit counts.
|
// The metrics are read from 127.0.0.1, which no limit counts.
|
||||||
"SWWAF_ALLOW_NETS": localhost + "/32",
|
"SWW_ALLOW_NETS": localhost + "/32",
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
instanceName: instance,
|
instanceName: instance,
|
||||||
"SWWAF_ALERT_SLACK_WEBHOOK_URL": slack.url,
|
"SWW_ALERT_SLACK_WEBHOOK_URL": slack.url,
|
||||||
"SWWAF_ALERT_NTFY_URL": ntfy.url,
|
"SWW_ALERT_NTFY_URL": ntfy.url,
|
||||||
"SWWAF_ALERT_NTFY_TOKEN": ntfyToken,
|
"SWW_ALERT_NTFY_TOKEN": ntfyToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
runUntilStopped(t, env, func(url string) {
|
runUntilStopped(t, env, func(url string) {
|
||||||
@@ -1079,13 +1130,13 @@ func TestBanIsAlertedToSlackAndNtfy(t *testing.T) {
|
|||||||
// webhook, which is not set. As long as that takes, so that a slow
|
// webhook, which is not set. As long as that takes, so that a slow
|
||||||
// test process cannot fail the test.
|
// test process cannot fail the test.
|
||||||
sent := []string{
|
sent := []string{
|
||||||
"\nsmallwebwaf_alerts_sent_total{destination=\"slack\"," +
|
"\nsww_alerts_sent_total{destination=\"slack\"," +
|
||||||
"instance=\"fsn1app1/blog\"} 1\n",
|
"instance=\"fsn1app1/blog\"} 1\n",
|
||||||
"\nsmallwebwaf_alerts_sent_total{destination=\"ntfy\"," +
|
"\nsww_alerts_sent_total{destination=\"ntfy\"," +
|
||||||
"instance=\"fsn1app1/blog\"} 1\n",
|
"instance=\"fsn1app1/blog\"} 1\n",
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics := metricsWith(t, url+"_smallwebwaf/metrics", token, sent...)
|
metrics := metricsWith(t, url+"_sww/metrics", token, sent...)
|
||||||
if strings.Contains(metrics, `destination="webhook"`) {
|
if strings.Contains(metrics, `destination="webhook"`) {
|
||||||
t.Errorf("the metrics give the webhook:\n%s", metrics)
|
t.Errorf("the metrics give the webhook:\n%s", metrics)
|
||||||
}
|
}
|
||||||
@@ -1111,10 +1162,10 @@ func TestUnwritableStateDirStopsTheStart(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
wantStartRefused(t, filepath.Join(t.TempDir(), "missing"),
|
wantStartRefused(t, filepath.Join(t.TempDir(), "missing"),
|
||||||
"SWWAF_STATE_DIR cannot be written: ")
|
"SWW_STATE_DIR cannot be written: ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantStartRefused runs smallwebwaf with its state files in dir, and
|
// wantStartRefused runs sww with its state files in dir, and
|
||||||
// checks that it stops at start, with an error that starts with want. If
|
// checks that it stops at start, with an error that starts with want. If
|
||||||
// it starts instead, it is stopped after waitLimit.
|
// it starts instead, it is stopped after waitLimit.
|
||||||
func wantStartRefused(t *testing.T, dir, want string) {
|
func wantStartRefused(t *testing.T, dir, want string) {
|
||||||
@@ -1140,7 +1191,7 @@ func wantStartRefused(t *testing.T, dir, want string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantRulesRefused runs smallwebwaf with its rule files in dir, and
|
// wantRulesRefused runs sww with its rule files in dir, and
|
||||||
// checks that it stops at start, with the error want. If it starts
|
// checks that it stops at start, with the error want. If it starts
|
||||||
// instead, it is stopped after waitLimit.
|
// instead, it is stopped after waitLimit.
|
||||||
func wantRulesRefused(t *testing.T, dir, want string) {
|
func wantRulesRefused(t *testing.T, dir, want string) {
|
||||||
@@ -1178,7 +1229,7 @@ func startApp(t *testing.T) string {
|
|||||||
return app.URL
|
return app.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// runUntilStopped runs `smallwebwaf servewaf` with the settings in env, has use
|
// runUntilStopped runs `sww discriminator` with the settings in env, has use
|
||||||
// send it requests at url, then stops it as SIGTERM does, checks that it
|
// send it requests at url, then stops it as SIGTERM does, checks that it
|
||||||
// stopped in order, and returns its output.
|
// stopped in order, and returns its output.
|
||||||
func runUntilStopped(
|
func runUntilStopped(
|
||||||
@@ -1219,26 +1270,26 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
|
|||||||
want := map[string]any{
|
want := map[string]any{
|
||||||
listenAddr: localhost + ":0",
|
listenAddr: localhost + ":0",
|
||||||
stateDir: dir,
|
stateDir: dir,
|
||||||
"SWWAF_MODE": "enforce",
|
"SWW_MODE": "enforce",
|
||||||
stateWriteDelay: "10s",
|
stateWriteDelay: "10s",
|
||||||
stateCounterInterval: "15m",
|
stateCounterInterval: "15m",
|
||||||
"SWWAF_ALLOW_NETS": "",
|
"SWW_ALLOW_NETS": "",
|
||||||
rateLimitExemptNets: "",
|
rateLimitExemptNets: "",
|
||||||
"SWWAF_DENY_NETS": "",
|
"SWW_DENY_NETS": "",
|
||||||
"SWWAF_RATE_LIMIT_PER_MINUTE": "120",
|
"SWW_RATE_LIMIT_PER_MINUTE": "120",
|
||||||
"SWWAF_RATE_LIMIT_PER_HOUR": "1500",
|
"SWW_RATE_LIMIT_PER_HOUR": "1500",
|
||||||
rateLimitPerDay: "6000",
|
rateLimitPerDay: "6000",
|
||||||
"SWWAF_RATE_LIMIT_EXEMPT_PATHS": "",
|
"SWW_RATE_LIMIT_EXEMPT_PATHS": "",
|
||||||
"SWWAF_DENIED_COUNTRIES": "",
|
"SWW_DENIED_COUNTRIES": "",
|
||||||
"SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
"SWW_EXCLUSIVELY_ALLOWED_COUNTRIES": "",
|
||||||
"SWWAF_BAN_RESPONSE": "403",
|
"SWW_BAN_RESPONSE": "403",
|
||||||
"SWWAF_LIMIT_BAN_DURATION": "1h",
|
"SWW_LIMIT_BAN_DURATION": "1h",
|
||||||
"SWWAF_LIMIT_BAN_REPEAT_WINDOW": "24h",
|
"SWW_LIMIT_BAN_REPEAT_WINDOW": "24h",
|
||||||
"SWWAF_MAX_BAN_DURATION": "7d",
|
"SWW_MAX_BAN_DURATION": "7d",
|
||||||
"SWWAF_ATTACK_BAN_DURATION": "7d",
|
"SWW_ATTACK_BAN_DURATION": "7d",
|
||||||
"SWWAF_MAX_BANS": "5000",
|
"SWW_MAX_BANS": "5000",
|
||||||
"SWWAF_BAN_SCOPE_V4_PREFIX": "32",
|
"SWW_BAN_SCOPE_V4_PREFIX": "32",
|
||||||
"SWWAF_RULES_ENABLED": "true",
|
"SWW_RULES_ENABLED": "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, value := range want {
|
for name, value := range want {
|
||||||
@@ -1252,7 +1303,7 @@ func wantStartingLine(t *testing.T, line map[string]any, dir string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantAllowed checks that servewaf at url allows a request from localhost.
|
// wantAllowed checks that discriminator at url allows a request from localhost.
|
||||||
func wantAllowed(t *testing.T, url string) {
|
func wantAllowed(t *testing.T, url string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -1437,8 +1488,8 @@ func onlyBan(t *testing.T, dir string) map[string]any {
|
|||||||
return file.Bans[0]
|
return file.Bans[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantRefused checks that servewaf at url refuses a request from localhost with
|
// wantRefused checks that discriminator at url refuses a request from localhost with
|
||||||
// 403, the default SWWAF_BAN_RESPONSE.
|
// 403, the default SWW_BAN_RESPONSE.
|
||||||
func wantRefused(t *testing.T, url string) {
|
func wantRefused(t *testing.T, url string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -1459,7 +1510,7 @@ func wantStatus(t *testing.T, url, from string, status int) {
|
|||||||
// saveUntilAnswered writes content to the state file at path, as an
|
// saveUntilAnswered writes content to the state file at path, as an
|
||||||
// admin saves an edit of it, until a request to url from the client at
|
// admin saves an edit of it, until a request to url from the client at
|
||||||
// from is answered with status. The file is written again before each
|
// from is answered with status. The file is written again before each
|
||||||
// request, since smallwebwaf may not watch its directory yet when it is
|
// request, since sww may not watch its directory yet when it is
|
||||||
// first written. It waits as long as that takes, so that a slow test
|
// first written. It waits as long as that takes, so that a slow test
|
||||||
// process cannot fail the test.
|
// process cannot fail the test.
|
||||||
func saveUntilAnswered(t *testing.T, path, content, url, from string, status int) {
|
func saveUntilAnswered(t *testing.T, path, content, url, from string, status int) {
|
||||||
@@ -1479,8 +1530,8 @@ func saveUntilAnswered(t *testing.T, path, content, url, from string, status int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// destination is a stand-in for SWWAF_ALERT_SLACK_WEBHOOK_URL or
|
// destination is a stand-in for SWW_ALERT_SLACK_WEBHOOK_URL or
|
||||||
// SWWAF_ALERT_NTFY_URL. It notes each request it is sent, and answers
|
// SWW_ALERT_NTFY_URL. It notes each request it is sent, and answers
|
||||||
// 200.
|
// 200.
|
||||||
type destination struct {
|
type destination struct {
|
||||||
url string
|
url string
|
||||||
@@ -1539,7 +1590,7 @@ func (d *destination) firstPost(t *testing.T) destinationPost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// webhook is a stand-in for SWWAF_ALERT_WEBHOOK_URL. It notes each alert
|
// webhook is a stand-in for SWW_ALERT_WEBHOOK_URL. It notes each alert
|
||||||
// it is sent, and answers 204, or 503 while failing.
|
// it is sent, and answers 204, or 503 while failing.
|
||||||
type webhook struct {
|
type webhook struct {
|
||||||
url string
|
url string
|
||||||
@@ -1614,8 +1665,8 @@ func (w *webhook) waitFor(t *testing.T, event string, answered bool) webhookPost
|
|||||||
}
|
}
|
||||||
|
|
||||||
// statusFrom returns the status a request to url from the client at from is
|
// statusFrom returns the status a request to url from the client at from is
|
||||||
// answered with. The request serves rproxy and servewaf alike. rproxy reads the
|
// answered with. The request serves rproxy and discriminator alike. rproxy reads the
|
||||||
// client from X-Forwarded-For, and replaces the other two headers. To servewaf,
|
// client from X-Forwarded-For, and replaces the other two headers. To discriminator,
|
||||||
// they make it a question about that client.
|
// they make it a question about that client.
|
||||||
func statusFrom(t *testing.T, url, from string) int {
|
func statusFrom(t *testing.T, url, from string) int {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -1627,7 +1678,7 @@ func statusFrom(t *testing.T, url, from string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("X-Forwarded-For", from)
|
req.Header.Set("X-Forwarded-For", from)
|
||||||
req.Header.Set(servewaf.ClientHeader, from)
|
req.Header.Set(discriminator.ClientHeader, from)
|
||||||
req.Header.Set("X-Request-ID", rand.Text())
|
req.Header.Set("X-Request-ID", rand.Text())
|
||||||
|
|
||||||
transport := &http.Transport{}
|
transport := &http.Transport{}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
# The eighth change: on gitea's paths, some fields skip the rules that refused
|
||||||
|
# ordinary gitea requests there. They are the fields where gitea sends a search,
|
||||||
|
# a file name or a branch name. Each exclusion runs with the request, before the
|
||||||
|
# rules it switches off, those of phase 1 included, so this file is loaded before
|
||||||
|
# the Core Rule Set's rules. Each comment names a request the rule refused.
|
||||||
|
|
||||||
|
# gitea's searches of code, of issues and pull requests, and of commits send the
|
||||||
|
# text searched for in q, which skips these rules there:
|
||||||
|
# 930110: ../
|
||||||
|
# 930120: .gitignore
|
||||||
|
# 931100: http://127.0.0.1:8080
|
||||||
|
# 932120: Invoke-WebRequest
|
||||||
|
# 932130: $(pwd)
|
||||||
|
# 932140: if (a == b) {
|
||||||
|
# 932160: /bin/sh
|
||||||
|
# 932235: `make check`
|
||||||
|
# 932250: ls -la
|
||||||
|
# 932260: python3
|
||||||
|
# 932380: `whoami`
|
||||||
|
# 933100: <?php echo
|
||||||
|
# 933130: $_GET[
|
||||||
|
# 933135: ${VAR}
|
||||||
|
# 934100: process.env
|
||||||
|
# 934110: http://127.0.0.1:8080
|
||||||
|
# 934130: __proto__
|
||||||
|
# 941100: <script>
|
||||||
|
# 941110: <script>
|
||||||
|
# 941160: <img src=
|
||||||
|
# 941180: document.cookie
|
||||||
|
# 941390: fetch(
|
||||||
|
# 942100: SELECT * FROM users WHERE
|
||||||
|
# 942140: information_schema
|
||||||
|
# 942190: union select
|
||||||
|
# 942230: if (a == b) {
|
||||||
|
# 944100: java.lang.Runtime
|
||||||
|
# 944110: java.lang.Runtime
|
||||||
|
# 944130: java.lang.Runtime
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/(?:explore/code|issues(?:/search)?|pulls|\
|
||||||
|
[^/]+/-/code|\
|
||||||
|
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|\
|
||||||
|
commits/(?:branch|tag|commit)/.+/search)|\
|
||||||
|
api/v1/repos/(?:issues/search|[^/]+/[^/]+/issues))$" \
|
||||||
|
"id:900600,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=930110;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=931100;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932130;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932140;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932235;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932250;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932380;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=933100;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=933130;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=933135;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=934100;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=934110;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=934130;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=941100;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=941110;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=941160;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=941180;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=941390;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=942100;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=942140;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=942190;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=942230;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=944100;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=944110;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=944130;ARGS:q"
|
||||||
|
|
||||||
|
# A page opened from the results of one of those searches has the search's
|
||||||
|
# address in Referer, which skips these rules then:
|
||||||
|
# 941110: <script>
|
||||||
|
# 941120: onClick={
|
||||||
|
# 941160: <img src=
|
||||||
|
# 942100: LIKE '%foo%'
|
||||||
|
# 944100: java.lang.Runtime
|
||||||
|
SecRule REQUEST_HEADERS:Referer "@rx ^https?://[^/]+/(?:explore/code|\
|
||||||
|
issues(?:/search)?|pulls|[^/]+/-/code|\
|
||||||
|
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|\
|
||||||
|
commits/(?:branch|tag|commit)/.+/search)|\
|
||||||
|
api/v1/repos/(?:issues/search|[^/]+/[^/]+/issues))\?" \
|
||||||
|
"id:900601,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=941110;REQUEST_HEADERS:Referer,\
|
||||||
|
ctl:ruleRemoveTargetById=941120;REQUEST_HEADERS:Referer,\
|
||||||
|
ctl:ruleRemoveTargetById=941160;REQUEST_HEADERS:Referer,\
|
||||||
|
ctl:ruleRemoveTargetById=942100;REQUEST_HEADERS:Referer,\
|
||||||
|
ctl:ruleRemoveTargetById=944100;REQUEST_HEADERS:Referer"
|
||||||
|
|
||||||
|
# gitea's searches of repositories, users, branches, tags and packages send a
|
||||||
|
# name in q, which skips these rules there:
|
||||||
|
# 930120: .profile
|
||||||
|
# 932120: powershell-scripts
|
||||||
|
# 932260: python3-app
|
||||||
|
# 944130: org.apache.commons.logging
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/(?:explore/repos|user/search_candidates|\
|
||||||
|
[^/]+/-/packages|[^/]+/[^/]+/(?:branches|tags)|api/v1/repos/search)$" \
|
||||||
|
"id:900602,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=944130;ARGS:q"
|
||||||
|
|
||||||
|
# gitea's other searches for a name send it in q too, which skips only these
|
||||||
|
# rules there:
|
||||||
|
# 930120: .profile
|
||||||
|
# 932120: powershell-scripts
|
||||||
|
# 932260: python3-app
|
||||||
|
# The searches:
|
||||||
|
# - the dashboard's filter of repositories;
|
||||||
|
# - the repositories on a user's or an organisation's page, whose path has
|
||||||
|
# one segment;
|
||||||
|
# - the author filter of the issue and pull request lists;
|
||||||
|
# - the lists of users and organisations, and the API's search for users.
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/(?:[^/]+|repo/search|\
|
||||||
|
explore/(?:users|organizations)|api/v1/users/search|\
|
||||||
|
[^/]+/[^/]+/(?:issues|pulls)/posters)$" \
|
||||||
|
"id:900603,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:q"
|
||||||
|
|
||||||
|
# These requests name a file in the path, which skips these rules there:
|
||||||
|
# 920500: notes.txt~
|
||||||
|
# 934100: process.env.d.ts
|
||||||
|
# 941130: docs/a b.xhtml
|
||||||
|
# 944130: java.lang.String.md
|
||||||
|
# The requests:
|
||||||
|
# - a file's page, raw file and download;
|
||||||
|
# - its blame, history, edit, preview and delete pages;
|
||||||
|
# - the API's call for its contents;
|
||||||
|
# - a release attachment's download.
|
||||||
|
SecRule REQUEST_FILENAME \
|
||||||
|
"@rx ^/(?:api/v1/repos/)?[^/]+/[^/]+/(?:src|raw|media|blame|commits|_edit|_preview|_delete|contents|releases/download)/" \
|
||||||
|
"id:900610,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=920500;REQUEST_FILENAME,\
|
||||||
|
ctl:ruleRemoveTargetById=934100;REQUEST_FILENAME,\
|
||||||
|
ctl:ruleRemoveTargetById=941130;REQUEST_FILENAME,\
|
||||||
|
ctl:ruleRemoveTargetById=944130;REQUEST_FILENAME"
|
||||||
|
|
||||||
|
# The requests below name a file in a query parameter. These rules refused
|
||||||
|
# them for the file named:
|
||||||
|
# 930120: .gitignore
|
||||||
|
# 932120: install-script.sh
|
||||||
|
# 932130: [draft].md
|
||||||
|
# 932160: bin/docker-entrypoint
|
||||||
|
# 932260: docker-compose.yml
|
||||||
|
# 934100: process.env.d.ts
|
||||||
|
# 934130: __proto__.json
|
||||||
|
# 941130: index.xhtml
|
||||||
|
# 941180: document.write.js
|
||||||
|
# 942140: information_schema.sql
|
||||||
|
# 944110: java/lang/Runtime.java
|
||||||
|
# 944130: java.lang.String.md
|
||||||
|
# Each request skips the rules that refused it. The fourth change already has
|
||||||
|
# path and redirect_to skip 930120, 932160 and 932260 on every path.
|
||||||
|
|
||||||
|
# A file's commit graph, in file.
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
|
||||||
|
"id:900620,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:file,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:file,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:file,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:file,\
|
||||||
|
ctl:ruleRemoveTargetById=934100;ARGS:file,\
|
||||||
|
ctl:ruleRemoveTargetById=934130;ARGS:file,\
|
||||||
|
ctl:ruleRemoveTargetById=941130;ARGS:file,\
|
||||||
|
ctl:ruleRemoveTargetById=941180;ARGS:file,\
|
||||||
|
ctl:ruleRemoveTargetById=942140;ARGS:file,\
|
||||||
|
ctl:ruleRemoveTargetById=944110;ARGS:file,\
|
||||||
|
ctl:ruleRemoveTargetById=944130;ARGS:file"
|
||||||
|
|
||||||
|
# The API's list of a file's commits, in path.
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/commits$" \
|
||||||
|
"id:900621,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=934100;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=934130;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=941130;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=941180;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=942140;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=944110;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=944130;ARGS:path"
|
||||||
|
|
||||||
|
# The API's call for the contents of several files, in body, as JSON.
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/file-contents$" \
|
||||||
|
"id:900622,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:body,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:body,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:body,\
|
||||||
|
ctl:ruleRemoveTargetById=934100;ARGS:body,\
|
||||||
|
ctl:ruleRemoveTargetById=934130;ARGS:body,\
|
||||||
|
ctl:ruleRemoveTargetById=941130;ARGS:body,\
|
||||||
|
ctl:ruleRemoveTargetById=941180;ARGS:body,\
|
||||||
|
ctl:ruleRemoveTargetById=942140;ARGS:body,\
|
||||||
|
ctl:ruleRemoveTargetById=944110;ARGS:body,\
|
||||||
|
ctl:ruleRemoveTargetById=944130;ARGS:body"
|
||||||
|
|
||||||
|
# An issue opened from a template, in template.
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/issues/new$" \
|
||||||
|
"id:900623,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:template,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:template,\
|
||||||
|
ctl:ruleRemoveTargetById=932130;ARGS:template,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:template,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:template,\
|
||||||
|
ctl:ruleRemoveTargetById=934100;ARGS:template,\
|
||||||
|
ctl:ruleRemoveTargetById=934130;ARGS:template,\
|
||||||
|
ctl:ruleRemoveTargetById=941130;ARGS:template,\
|
||||||
|
ctl:ruleRemoveTargetById=941180;ARGS:template,\
|
||||||
|
ctl:ruleRemoveTargetById=942140;ARGS:template,\
|
||||||
|
ctl:ruleRemoveTargetById=944110;ARGS:template,\
|
||||||
|
ctl:ruleRemoveTargetById=944130;ARGS:template"
|
||||||
|
|
||||||
|
# The sign-in page on the way to a file's edit page, in redirect_to.
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/user/login$" \
|
||||||
|
"id:900624,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:redirect_to,\
|
||||||
|
ctl:ruleRemoveTargetById=932130;ARGS:redirect_to,\
|
||||||
|
ctl:ruleRemoveTargetById=934100;ARGS:redirect_to,\
|
||||||
|
ctl:ruleRemoveTargetById=934130;ARGS:redirect_to,\
|
||||||
|
ctl:ruleRemoveTargetById=941130;ARGS:redirect_to,\
|
||||||
|
ctl:ruleRemoveTargetById=941180;ARGS:redirect_to,\
|
||||||
|
ctl:ruleRemoveTargetById=942140;ARGS:redirect_to,\
|
||||||
|
ctl:ruleRemoveTargetById=944110;ARGS:redirect_to,\
|
||||||
|
ctl:ruleRemoveTargetById=944130;ARGS:redirect_to"
|
||||||
|
|
||||||
|
# An attachment uploaded through the API to a release, an issue or a comment,
|
||||||
|
# in name.
|
||||||
|
SecRule REQUEST_FILENAME \
|
||||||
|
"@rx ^/api/v1/repos/[^/]+/[^/]+/(?:releases|issues|issues/comments)/[0-9]+/assets$" \
|
||||||
|
"id:900625,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:name,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:name,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:name,\
|
||||||
|
ctl:ruleRemoveTargetById=934100;ARGS:name,\
|
||||||
|
ctl:ruleRemoveTargetById=934130;ARGS:name,\
|
||||||
|
ctl:ruleRemoveTargetById=941130;ARGS:name,\
|
||||||
|
ctl:ruleRemoveTargetById=941180;ARGS:name,\
|
||||||
|
ctl:ruleRemoveTargetById=942140;ARGS:name,\
|
||||||
|
ctl:ruleRemoveTargetById=944130;ARGS:name"
|
||||||
|
|
||||||
|
# A diff's hidden lines, fetched when shown, in path.
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/blob_excerpt/[0-9a-f]+$" \
|
||||||
|
"id:900626,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=934100;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=934130;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=941130;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=941180;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=942140;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=944110;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=944130;ARGS:path"
|
||||||
|
|
||||||
|
# The requests below name a branch or tag in a query parameter, which skips
|
||||||
|
# the list of command names (932260) there. It refused names such as
|
||||||
|
# docker-build, python3-migration and feat/docker-support.
|
||||||
|
|
||||||
|
# Deleting or restoring a branch, in name.
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/branches/(?:delete|restore)$" \
|
||||||
|
"id:900630,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:name"
|
||||||
|
|
||||||
|
# A release started from a tag, in tag.
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/releases/new$" \
|
||||||
|
"id:900631,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:tag"
|
||||||
|
|
||||||
|
# A branch protection rule opened for editing, in rule_name.
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/settings/branches/edit$" \
|
||||||
|
"id:900632,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:rule_name"
|
||||||
|
|
||||||
|
# A directory listing's last commits, in refSubUrl.
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/lastcommit/" \
|
||||||
|
"id:900633,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:refSubUrl"
|
||||||
|
|
||||||
|
# An artifact upload from actions/upload-artifact@v3 sends the header
|
||||||
|
# Content-Range, which the Core Rule Set refused (920450).
|
||||||
|
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"
|
||||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user