Compare commits

..
Author SHA1 Message Date
sneak a93243a294 Set the images' own OCI labels instead of Ubuntu's (closes #216)
check / check (push) Failing after 1m53s
The image sets title, description, version, created, revision and
source; the last three known only at build time come from build
arguments that default to unknown, and script/docker and
script/cibuild pass them. The example app sets its own title and
description.

Model: opus-5-5
2026-11-14 17:58:09 +00:00
clawbot 89a60cf9fc README: blocks misbehaving scrapers, not normal ones (closes #387)
check / check (push) Failing after 5m11s
The opening now lists what bans a client with the default settings: the
rate and byte limits, a probe for a secret path, a security scanner's
user agent, and the refusal limit. A scraper that does none of these is
left alone.

Model: opus-5-5
2026-11-14 18:56:10 +01:00
clawbot a05a82b1af Docs: rproxy runs behind any TLS-terminating reverse proxy (closes #388)
check / check (push) Failing after 2m26s
README, SPEC and docs no longer present traefik as the required front
proxy, and call it the TLS-terminating proxy. The README section of that
name lists what the proxy must do: forward plain HTTP to port 8080, keep
Host, set X-Forwarded-For and X-Forwarded-Proto, close an unused
connection in under 120 seconds, and connect from an address in
SWW_RPROXY_TRUSTED_PROXIES. traefik, Caddy and nginx appear as examples,
with the Caddy and nginx settings their defaults lack.

Model: opus-5-5
2026-11-14 18:41:10 +01:00
8 changed files with 104 additions and 54 deletions

No files matched your search

+14
View File
@@ -161,6 +161,20 @@ RUN git checkout --quiet --detach b4b2c785308b1ce785b6155c7fe5f16879080193 \
# ubuntu 26.04, 2026-09-27 # ubuntu 26.04, 2026-09-27
FROM ubuntu@sha256:f144425ff09be612d6d9ad965196e9cdc23dae1f42110a8a11a3e9a8198759f7 FROM ubuntu@sha256:f144425ff09be612d6d9ad965196e9cdc23dae1f42110a8a11a3e9a8198759f7
# The image's own labels, in place of the ones it would inherit from
# Ubuntu's. script/docker and script/cibuild pass the build arguments;
# a build that passes none labels the image unknown rather than leave
# Ubuntu's values.
ARG VERSION=unknown
ARG CREATED=unknown
ARG REVISION=unknown
LABEL org.opencontainers.image.title="smallwebwaf" \
org.opencontainers.image.description="A web application firewall for self-hosted web apps: sww rproxy in front of an app, and sww waf, which decides." \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.created="${CREATED}" \
org.opencontainers.image.revision="${REVISION}" \
org.opencontainers.image.source="https://git.eeqj.de/sneak/smallwebwaf"
# runit's install creates its _runit-log user with minsysusers, which # runit's install creates its _runit-log user with minsysusers, which
# reads this file in place of runit's /usr/lib/sysusers.d/runit.conf. # reads this file in place of runit's /usr/lib/sysusers.d/runit.conf.
# runit's line leaves out the shell, and minsysusers prints a Perl # runit's line leaves out the shell, and minsysusers prints a Perl
+47 -23
View File
@@ -2,7 +2,20 @@
`smallwebwaf` is an MIT-licensed web application firewall, written in Go by `smallwebwaf` is an MIT-licensed web application firewall, written in Go by
[@sneak](https://sneak.berlin), for self-hosted web apps. It refuses the [@sneak](https://sneak.berlin), for self-hosted web apps. It refuses the
requests of scrapers, scanners and other abusers before they reach an app. requests of misbehaving scrapers, scanners and other abusers before they reach
an app.
With the default settings, a client is banned when it does any of these:
- makes more than 120 requests in a minute, 1500 in an hour or 6000 in a day;
- moves more than `50G` of request and response bodies in an hour, or `100G` in
a day;
- asks for a secret or backup file, such as `/.env` or `/.git/config`;
- sends a user agent that names a security scanner, such as `sqlmap` or `nikto`;
- has more than 30 requests in a minute refused as probes or attacks.
A scraper that does none of these is left alone. A scraper that waits 15 seconds
between requests stays under the rate limits.
## What it does ## What it does
@@ -10,11 +23,10 @@ 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 program, `sww`, refuses the ones that look like abuse. `smallwebwaf` is one program, `sww`,
shipped in one image, with two commands that do this together: shipped in one image, with two commands that do this together:
- `sww rproxy` is a small reverse proxy in front of one app. It sits behind the - `sww rproxy` is a small reverse proxy in front of one app. It sits behind a
reverse proxy that terminates TLS, such as traefik, nginx or Caddy. It asks TLS-terminating proxy, such as traefik, nginx or Caddy. It asks `waf` about
`waf` about each request, passes the allowed ones to the app, and passes the each request, passes the allowed ones to the app, and passes the app's answers
app's answers back. It keeps nothing between requests and writes nothing to back. It keeps nothing between requests and writes nothing to disk.
disk.
- `sww waf` decides. It runs the checks and keeps the bans, the counters and - `sww waf` decides. It runs the checks and keeps the bans, the counters and
each client's history. One `waf` serves many sites: a client banned through each client's history. One `waf` serves many sites: a client banned through
one site is refused on all of them, and its requests on all of them count one site is refused on all of them, and its requests on all of them count
@@ -58,8 +70,7 @@ For the operator, it:
Not built, and not planned: Not built, and not planned:
- TLS and routing by host name. Both stay with the reverse proxy in front of - TLS and routing by host name. Both stay with the TLS-terminating proxy.
`rproxy`.
- Browser challenges such as captchas. - Browser challenges such as captchas.
- A web console, or a configuration file. - A web console, or a configuration file.
- Defence against floods large enough to fill the host's network link. - Defence against floods large enough to fill the host's network link.
@@ -91,9 +102,9 @@ accepts connections, whether or not `waf` answers; in `waf`'s container, once
The usual setup is three containers on one private network: The usual setup is three containers on one private network:
- the app; - the app;
- `rproxy`, in front of the app, which the reverse proxy that terminates TLS - `rproxy`, in front of the app, which the TLS-terminating proxy forwards to:
forwards to: `SWW_RPROXY_WAF_BACKEND` names `waf` and `SWW_RPROXY_WAF_BACKEND` names `waf` and `SWW_RPROXY_UPSTREAM_URL` names the
`SWW_RPROXY_UPSTREAM_URL` names the app; app;
- `waf`, with `SWW_COMMAND=waf` and a volume at `/var/lib/sww` for its state - `waf`, with `SWW_COMMAND=waf` and a volume at `/var/lib/sww` for its state
files. files.
@@ -103,26 +114,39 @@ you trust. Each further app adds its own container and its own `rproxy`, whose
`SWW_RPROXY_WAF_BACKEND` names the same `waf`. The "Smallest setup" example `SWW_RPROXY_WAF_BACKEND` names the same `waf`. The "Smallest setup" example
below shows this shape. below shows this shape.
### The reverse proxy in front ### The TLS-terminating proxy
`rproxy` serves plain HTTP. It runs behind any reverse proxy that terminates `rproxy` serves plain HTTP. It runs behind any TLS-terminating proxy, such as
TLS, such as traefik, nginx or Caddy. That proxy must: traefik, nginx or Caddy. The TLS-terminating proxy must:
- forward each request to port 8080 of `rproxy` over plain HTTP; - forward each request to port 8080 of `rproxy` over plain HTTP;
- keep the visitor's `Host` header; - keep the visitor's `Host` header;
- add the visitor's address to `X-Forwarded-For`; - add the visitor's address to `X-Forwarded-For`;
- set `X-Forwarded-Proto` to the scheme the visitor used; - set `X-Forwarded-Proto` to the scheme the visitor used;
- close an unused connection to `rproxy` in under 120 seconds;
- connect to `rproxy` from an address in `SWW_RPROXY_TRUSTED_PROXIES`, by - connect to `rproxy` from an address in `SWW_RPROXY_TRUSTED_PROXIES`, by
default `10.0.0.0/8`, `172.16.0.0/12` or `192.168.0.0/16`. default `10.0.0.0/8`, `172.16.0.0/12` or `192.168.0.0/16`.
From any other address, `rproxy` ignores the forwarded headers and takes the From any other address, `rproxy` ignores the forwarded headers and takes the
proxy for the visitor. Every visitor then shares one address, and one ban. TLS-terminating proxy for the visitor. Every visitor then shares one address,
and one ban.
traefik and Caddy keep the `Host` header and set both forwarded headers by traefik and Caddy keep the `Host` header and set both forwarded headers by
default. traefik's Docker label default. traefik's Docker label
`traefik.http.services.<name>.loadbalancer.server.port=8080` names the port, and `traefik.http.services.<name>.loadbalancer.server.port=8080` names the port.
Caddy's `reverse_proxy rproxy:8080` needs nothing more. nginx sets none of the Caddy keeps an unused connection for 120 seconds by default, so its site
headers by default, so its site names them: shortens that to 90 seconds:
```caddyfile
reverse_proxy rproxy:8080 {
transport http {
keepalive 90s
}
}
```
By default nginx sends the host in `proxy_pass` as `Host` and sets neither
forwarded header, so its site sets all three:
```nginx ```nginx
location / { location / {
@@ -183,8 +207,8 @@ entrypoint starts `rproxy` and the app side by side, each as its own user. Run
the app's image with `SWW_RPROXY_WAF_BACKEND` set. `deploy/example-app` is such the app's image with `SWW_RPROXY_WAF_BACKEND` set. `deploy/example-app` is such
an app, and the "`rproxy` beside the app" example below runs one. an app, and the "`rproxy` beside the app" example below runs one.
The app sees the headers the reverse proxy in front sent, with that proxy's The app's TCP peer is `rproxy`. The app sees the headers the TLS-terminating
address added to `X-Forwarded-For`, from a TCP peer that is `rproxy`. So the app proxy sent, with that proxy's address added to `X-Forwarded-For`. So the app
must trust `rproxy`'s address for forwarded headers. In a container of its own, must trust `rproxy`'s address for forwarded headers. In a container of its own,
`rproxy` is on the private network, which the usual default of a trusted-proxy `rproxy` is on the private network, which the usual default of a trusted-proxy
setting covers. Beside the app, its address is `127.0.0.1` or `::1`, which the setting covers. Beside the app, its address is `127.0.0.1` or `::1`, which the
@@ -197,9 +221,9 @@ private ranges leave out, so the app's own setting must name them too, as
`healthy` for each container of `smallwebwaf`. `healthy` for each container of `smallwebwaf`.
- 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 the reverse proxy in front gets the app's - A request to the site through the TLS-terminating proxy gets the app's answer.
answer. `https://<site>/_sww/healthz` answers `ok`. `waf`'s log lines about `https://<site>/_sww/healthz` answers `ok`. `waf`'s log lines about the
the request have `"action":"forward"`. request have `"action":"forward"`.
- To see a refusal without banning yourself, run `waf` with - To see a refusal without banning yourself, run `waf` with
`SWW_WAF_MODE=observe` and ask for `/.env`. The request reaches the app. `SWW_WAF_MODE=observe` and ask for `/.env`. The request reaches the app.
`waf`'s log lines have `"would_action":"temporarily_banned"` and `waf`'s log lines have `"would_action":"temporarily_banned"` and
+24 -26
View File
@@ -3,8 +3,7 @@
`smallwebwaf` is a web application firewall for self-hosted apps. It is one `smallwebwaf` is a web application firewall for self-hosted apps. It is one
binary, `sww`, and two of its commands do the work: binary, `sww`, and two of its commands do the work:
- `sww rproxy` is a reverse proxy between one app and the reverse proxy that - `sww rproxy` is a reverse proxy between a TLS-terminating proxy and one app.
terminates TLS.
- `sww waf` decides what happens to each request, for many sites. - `sww waf` decides what happens to each request, for many sites.
This document says what it must do and why, each requirement once. Everything in This document says what it must do and why, each requirement once. Everything in
@@ -46,8 +45,8 @@ never as a time.
## Non-goals ## Non-goals
- TLS, certificates and routing by host name. Those are the job of the reverse - TLS, certificates and routing by host name. Those are the job of the
proxy in front of `rproxy`, such as traefik, nginx or Caddy. TLS-terminating proxy, such as traefik, nginx or Caddy.
- More than one app behind one `rproxy`. Each app has its own. - More than one app behind one `rproxy`. Each app has its own.
- Browser challenges such as captchas or proof of work. If wanted, chain Anubis - Browser challenges such as captchas or proof of work. If wanted, chain Anubis
between `rproxy` and the app. between `rproxy` and the app.
@@ -1946,7 +1945,7 @@ leave out a cookie only when its name matches exactly, case included.
`SWW_WAF_BAN_RESPONSE` is `403` by default, which makes a ban easy to recognise `SWW_WAF_BAN_RESPONSE` is `403` by default, which makes a ban easy to recognise
when debugging; `429` and `close` are the alternatives. `close` does not leave when debugging; `429` and `close` are the alternatives. `close` does not leave
the client unanswered: the reverse proxy in front answers `502`, as it does the client unanswered: the TLS-terminating proxy answers `502`, as it does
whenever its backend drops a connection. `SWW_WAF_BAN_SCOPE_V4_PREFIX` widens an whenever its backend drops a connection. `SWW_WAF_BAN_SCOPE_V4_PREFIX` widens an
IPv4 ban to the surrounding netblock. The four ban lengths are under "Bans". IPv4 ban to the surrounding netblock. The four ban lengths are under "Bans".
@@ -2773,10 +2772,9 @@ its whole body. The visitor gets that answer at once, and the connection closes
after it. after it.
The largest request line and headers is 32 KiB. A kept-open connection is closed The largest request line and headers is 32 KiB. A kept-open connection is closed
after 120 seconds idle. Neither has a setting. The reverse proxy in front should after 120 seconds idle. Neither has a setting. The TLS-terminating proxy should
close an unused connection sooner, so that it never sends a request on a close an unused connection sooner, so that it never sends a request on a
connection `rproxy` is closing. traefik closes one after 90 seconds by default. connection `rproxy` is closing. traefik closes one after 90 seconds by default.
nginx, by default, opens a new connection for each request.
What the visitor gets when a limit is passed: What the visitor gets when a limit is passed:
@@ -2827,7 +2825,7 @@ chooses: `waf` runs `waf`, and with it unset the image runs `rproxy`.
The usual setup is three containers on one private network: The usual setup is three containers on one private network:
- the app; - the app;
- `rproxy`, which the reverse proxy that terminates TLS forwards to, with - `rproxy`, which the TLS-terminating proxy forwards to, with
`SWW_RPROXY_UPSTREAM_URL` naming the app and `SWW_RPROXY_WAF_BACKEND` naming `SWW_RPROXY_UPSTREAM_URL` naming the app and `SWW_RPROXY_WAF_BACKEND` naming
`waf`; `waf`;
- `waf`, with `SWW_COMMAND=waf` and a volume for its state files. - `waf`, with `SWW_COMMAND=waf` and a volume for its state files.
@@ -2835,8 +2833,8 @@ The usual setup is three containers on one private network:
Each further app adds its own container and its own `rproxy`, whose Each further app adds its own container and its own `rproxy`, whose
`SWW_RPROXY_WAF_BACKEND` names the same `waf` (see "Sites"). Anything on that `SWW_RPROXY_WAF_BACKEND` names the same `waf` (see "Sites"). Anything on that
network can reach an app around `rproxy`, and can ask `waf` questions or send it network can reach an app around `rproxy`, and can ask `waf` questions or send it
reports. So the network holds only what you trust. "The reverse proxy in front" reports. So the network holds only what you trust. `README.md` lists what the
in `README.md` lists what that proxy must do. TLS-terminating proxy must do.
### `rproxy` in the app's container ### `rproxy` in the app's container
@@ -2908,10 +2906,10 @@ process that stops at start is started again every second, not in a tight loop.
container's output, which `docker logs` shows. container's output, which `docker logs` shows.
- When a service exits, runit runs its `run` script again, and the container - When a service exits, runit runs its `run` script again, and the container
does not stop. Beside an app, the other process keeps running. does not stop. Beside an app, the other process keeps running.
- While `rproxy` is down, the reverse proxy in front answers `502`. While the - While `rproxy` is down, the TLS-terminating proxy answers `502`. While the app
app is down, `rproxy` answers `502`, which is not a client offence. While is down, `rproxy` answers `502`, which is not a client offence. While `waf` is
`waf` is down, `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides (see down, `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides (see "Without an
"Without an answer from `waf`"). answer from `waf`").
- Only faults found at start stop `waf`: - Only faults found at start stop `waf`:
- invalid configuration; - invalid configuration;
- a lookup database that is missing or unreadable; - a lookup database that is missing or unreadable;
@@ -2944,24 +2942,24 @@ checks:
- With `SWW_COMMAND` unset, empty or `rproxy`, it passes while `rproxy` answers - With `SWW_COMMAND` unset, empty or `rproxy`, it passes while `rproxy` answers
that request on `127.0.0.1`, at the port in `SWW_RPROXY_LISTEN_ADDR`, and the that request on `127.0.0.1`, at the port in `SWW_RPROXY_LISTEN_ADDR`, and the
app accepts connections at `SWW_RPROXY_UPSTREAM_URL`. It does not ask `waf`, app accepts connections at `SWW_RPROXY_UPSTREAM_URL`. It does not ask `waf`,
so that the site stays in the routes of the reverse proxy in front while `waf` so that the site stays in the TLS-terminating proxy's routes while `waf` is
is down. down.
- With any other value, it fails with a message naming `SWW_COMMAND` and its - With any other value, it fails with a message naming `SWW_COMMAND` and its
value. value.
A reverse proxy that reads Docker's health status, as traefik does, sends a A TLS-terminating proxy that reads Docker's health status, as traefik does,
container no requests until it shows as healthy. So the check runs every second sends a container no requests until it shows as healthy. So the check runs every
from the container's start until it first passes, for up to a minute, and every second from the container's start until it first passes, for up to a minute, and
30 seconds after that. An app with a health check of its own can replace the every 30 seconds after that. An app with a health check of its own can replace
image's `HEALTHCHECK` with one that checks both. the image's `HEALTHCHECK` with one that checks both.
### Ports ### Ports
`rproxy` listens on no port but the one in `SWW_RPROXY_LISTEN_ADDR`, 8080 by `rproxy` listens on no port but the one in `SWW_RPROXY_LISTEN_ADDR`, 8080 by
default, which the reverse proxy in front forwards to. It passes requests to the default, which the TLS-terminating proxy forwards to. It passes requests to the
app at `SWW_RPROXY_UPSTREAM_URL`, `http://127.0.0.1:8081` by default. Another app at `SWW_RPROXY_UPSTREAM_URL`, `http://127.0.0.1:8081` by default. Another
port may be set: the health check follows the setting, and the reverse proxy in port may be set: the health check follows the setting, and the TLS-terminating
front must name it, as traefik's port label proxy must name it, as traefik's port label
(`traefik.http.services.<name>.loadbalancer.server.port`) does. An app beside (`traefik.http.services.<name>.loadbalancer.server.port`) does. An app beside
`rproxy` must leave that port free. `rproxy` must leave that port free.
@@ -3004,9 +3002,9 @@ in `docs/settings.md` shows the commands.
### Forwarded headers ### Forwarded headers
The app's TCP peer is `rproxy`, and the `X-Forwarded-For` the app receives ends The app's TCP peer is `rproxy`, and the `X-Forwarded-For` the app receives ends
with the address of the reverse proxy in front, which `rproxy` adds after the with the TLS-terminating proxy's address, which `rproxy` adds after the
visitor's. So the app must trust `rproxy`'s address for forwarded headers, visitor's. So the app must trust `rproxy`'s address for forwarded headers,
besides the private address ranges that proxy is on. besides the private address ranges the TLS-terminating proxy is on.
- In a container of its own, `rproxy`'s address is on the private network. The - In a container of its own, `rproxy`'s address is on the private network. The
usual default for a trusted-proxy setting, the private ranges, covers it. usual default for a trusted-proxy setting, the private ranges, covers it.
+4
View File
@@ -9,6 +9,10 @@
ARG SWW_IMAGE=smallwebwaf ARG SWW_IMAGE=smallwebwaf
FROM ${SWW_IMAGE} FROM ${SWW_IMAGE}
# Its own title and description, in place of the smallwebwaf image's.
LABEL org.opencontainers.image.title="smallwebwaf-example-app" \
org.opencontainers.image.description="An example app built on the smallwebwaf 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
+2 -2
View File
@@ -68,8 +68,8 @@ Every one of these lines has:
### Request detail ### Request detail
- `request_id`: the id `rproxy` made for the request when it arrived. - `request_id`: the id `rproxy` made for the request when it arrived.
- `peer_ip`: the address of `rproxy`'s TCP peer, normally the reverse proxy in - `peer_ip`: the address of `rproxy`'s TCP peer, normally the TLS-terminating
front of it. proxy.
- `forwarded_for`: the `X-Forwarded-For` header the visitor's request came with. - `forwarded_for`: the `X-Forwarded-For` header the visitor's request came with.
- `client_group`: the client as rate limiting counts it, an IPv4 address as a - `client_group`: the client as rate limiting counts it, an IPv4 address as a
/32 or an IPv6 group. /32 or an IPv6 group.
+3 -3
View File
@@ -478,9 +478,9 @@ start, and an invalid value stops the start. Any can be given as a file, as a
`10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`): the netblocks whose `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`): the netblocks whose
`X-Forwarded-For` is believed. Set but empty, it trusts nothing. A client `X-Forwarded-For` is believed. Set but empty, it trusts nothing. A client
outside these netblocks cannot claim another address. Where other containers outside these netblocks cannot claim another address. Where other containers
reach `rproxy` directly, or visitors on a private network reach the reverse reach `rproxy` directly, or visitors on a private network reach the
proxy in front, they can name another address in the header. Narrowing the TLS-terminating proxy, they can name another address in the header. Narrowing
setting to that proxy's own network closes that gap. the setting to the TLS-terminating proxy's own network closes that gap.
## The image's command ## The image's command
+5
View File
@@ -20,8 +20,13 @@ main() {
# the version a build stage derives from the .git in the context. # the version a build stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
revision="$(git rev-parse HEAD 2>/dev/null || true)"
[ -n "$revision" ] || revision="unknown"
created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
docker build --no-cache \ docker build --no-cache \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
--build-arg REVISION="$revision" \
--build-arg CREATED="$created" \
-t "$("$SCRIPT_DIR/projectname")" . -t "$("$SCRIPT_DIR/projectname")" .
} }
+5
View File
@@ -16,8 +16,13 @@ main() {
# the version a build stage derives from the .git in the context. # the version a build stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
revision="$(git rev-parse HEAD 2>/dev/null || true)"
[ -n "$revision" ] || revision="unknown"
created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
docker build --no-cache \ docker build --no-cache \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
--build-arg REVISION="$revision" \
--build-arg CREATED="$created" \
-t "$("$SCRIPT_DIR/projectname")" . -t "$("$SCRIPT_DIR/projectname")" .
} }