Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a93243a294 | ||
|
|
89a60cf9fc | ||
|
|
a05a82b1af |
No files matched your search
+14
@@ -161,6 +161,20 @@ RUN git checkout --quiet --detach b4b2c785308b1ce785b6155c7fe5f16879080193 \
|
||||
# ubuntu 26.04, 2026-09-27
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -2,7 +2,20 @@
|
||||
|
||||
`smallwebwaf` is an MIT-licensed web application firewall, written in Go by
|
||||
[@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
|
||||
|
||||
@@ -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`,
|
||||
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
|
||||
reverse proxy that terminates TLS, such as traefik, nginx or Caddy. It asks
|
||||
`waf` about each request, passes the allowed ones to the app, and passes the
|
||||
app's answers back. It keeps nothing between requests and writes nothing to
|
||||
disk.
|
||||
- `sww rproxy` is a small reverse proxy in front of one app. It sits behind a
|
||||
TLS-terminating proxy, such as traefik, nginx or Caddy. It asks `waf` about
|
||||
each request, passes the allowed ones to the app, and passes the app's answers
|
||||
back. It keeps nothing between requests and writes nothing to disk.
|
||||
- `sww waf` decides. It runs the checks and keeps the bans, the counters and
|
||||
each client's history. One `waf` serves many sites: a client banned through
|
||||
one site is refused on all of them, and its requests on all of them count
|
||||
@@ -58,8 +70,7 @@ For the operator, it:
|
||||
|
||||
Not built, and not planned:
|
||||
|
||||
- TLS and routing by host name. Both stay with the reverse proxy in front of
|
||||
`rproxy`.
|
||||
- TLS and routing by host name. Both stay with the TLS-terminating proxy.
|
||||
- Browser challenges such as captchas.
|
||||
- A web console, or a configuration file.
|
||||
- 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 app;
|
||||
- `rproxy`, in front of the app, which the reverse proxy that terminates TLS
|
||||
forwards to: `SWW_RPROXY_WAF_BACKEND` names `waf` and
|
||||
`SWW_RPROXY_UPSTREAM_URL` names the app;
|
||||
- `rproxy`, in front of the app, which the TLS-terminating proxy forwards to:
|
||||
`SWW_RPROXY_WAF_BACKEND` names `waf` and `SWW_RPROXY_UPSTREAM_URL` names the
|
||||
app;
|
||||
- `waf`, with `SWW_COMMAND=waf` and a volume at `/var/lib/sww` for its state
|
||||
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
|
||||
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
|
||||
TLS, such as traefik, nginx or Caddy. That proxy must:
|
||||
`rproxy` serves plain HTTP. It runs behind any TLS-terminating proxy, such as
|
||||
traefik, nginx or Caddy. The TLS-terminating proxy must:
|
||||
|
||||
- forward each request to port 8080 of `rproxy` over plain HTTP;
|
||||
- keep the visitor's `Host` header;
|
||||
- add the visitor's address to `X-Forwarded-For`;
|
||||
- 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
|
||||
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
|
||||
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
|
||||
default. traefik's Docker label
|
||||
`traefik.http.services.<name>.loadbalancer.server.port=8080` names the port, and
|
||||
Caddy's `reverse_proxy rproxy:8080` needs nothing more. nginx sets none of the
|
||||
headers by default, so its site names them:
|
||||
`traefik.http.services.<name>.loadbalancer.server.port=8080` names the port.
|
||||
Caddy keeps an unused connection for 120 seconds by default, so its site
|
||||
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
|
||||
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
|
||||
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
|
||||
address added to `X-Forwarded-For`, from a TCP peer that is `rproxy`. So the app
|
||||
The app's TCP peer is `rproxy`. The app sees the headers the TLS-terminating
|
||||
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,
|
||||
`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
|
||||
@@ -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`.
|
||||
- A log line with `"msg":"starting"` gives the version, the address and every
|
||||
setting in effect, with secrets masked.
|
||||
- A request to the site through the reverse proxy in front gets the app's
|
||||
answer. `https://<site>/_sww/healthz` answers `ok`. `waf`'s log lines about
|
||||
the request have `"action":"forward"`.
|
||||
- A request to the site through the TLS-terminating proxy gets the app's answer.
|
||||
`https://<site>/_sww/healthz` answers `ok`. `waf`'s log lines about the
|
||||
request have `"action":"forward"`.
|
||||
- To see a refusal without banning yourself, run `waf` with
|
||||
`SWW_WAF_MODE=observe` and ask for `/.env`. The request reaches the app.
|
||||
`waf`'s log lines have `"would_action":"temporarily_banned"` and
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
`smallwebwaf` is a web application firewall for self-hosted apps. It is one
|
||||
binary, `sww`, and two of its commands do the work:
|
||||
|
||||
- `sww rproxy` is a reverse proxy between one app and the reverse proxy that
|
||||
terminates TLS.
|
||||
- `sww rproxy` is a reverse proxy between a TLS-terminating proxy and one app.
|
||||
- `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
|
||||
@@ -46,8 +45,8 @@ never as a time.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- TLS, certificates and routing by host name. Those are the job of the reverse
|
||||
proxy in front of `rproxy`, such as traefik, nginx or Caddy.
|
||||
- TLS, certificates and routing by host name. Those are the job of the
|
||||
TLS-terminating proxy, such as traefik, nginx or Caddy.
|
||||
- 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
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -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 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
|
||||
`waf`;
|
||||
- `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
|
||||
`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
|
||||
reports. So the network holds only what you trust. "The reverse proxy in front"
|
||||
in `README.md` lists what that proxy must do.
|
||||
reports. So the network holds only what you trust. `README.md` lists what the
|
||||
TLS-terminating proxy must do.
|
||||
|
||||
### `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.
|
||||
- When a service exits, runit runs its `run` script again, and the container
|
||||
does not stop. Beside an app, the other process keeps running.
|
||||
- While `rproxy` is down, the reverse proxy in front answers `502`. While the
|
||||
app is down, `rproxy` answers `502`, which is not a client offence. While
|
||||
`waf` is down, `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides (see
|
||||
"Without an answer from `waf`").
|
||||
- While `rproxy` is down, the TLS-terminating proxy answers `502`. While the app
|
||||
is down, `rproxy` answers `502`, which is not a client offence. While `waf` is
|
||||
down, `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides (see "Without an
|
||||
answer from `waf`").
|
||||
- Only faults found at start stop `waf`:
|
||||
- invalid configuration;
|
||||
- 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
|
||||
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`,
|
||||
so that the site stays in the routes of the reverse proxy in front while `waf`
|
||||
is down.
|
||||
so that the site stays in the TLS-terminating proxy's routes while `waf` is
|
||||
down.
|
||||
- With any other value, it fails with a message naming `SWW_COMMAND` and its
|
||||
value.
|
||||
|
||||
A reverse proxy that reads Docker's health status, as traefik does, sends a
|
||||
container no requests until it shows as healthy. So the check runs every second
|
||||
from the container's start until it first passes, for up to a minute, and every
|
||||
30 seconds after that. An app with a health check of its own can replace the
|
||||
image's `HEALTHCHECK` with one that checks both.
|
||||
A TLS-terminating proxy that reads Docker's health status, as traefik does,
|
||||
sends a container no requests until it shows as healthy. So the check runs every
|
||||
second from the container's start until it first passes, for up to a minute, and
|
||||
every 30 seconds after that. An app with a health check of its own can replace
|
||||
the image's `HEALTHCHECK` with one that checks both.
|
||||
|
||||
### Ports
|
||||
|
||||
`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
|
||||
port may be set: the health check follows the setting, and the reverse proxy in
|
||||
front must name it, as traefik's port label
|
||||
port may be set: the health check follows the setting, and the TLS-terminating
|
||||
proxy must name it, as traefik's port label
|
||||
(`traefik.http.services.<name>.loadbalancer.server.port`) does. An app beside
|
||||
`rproxy` must leave that port free.
|
||||
|
||||
@@ -3004,9 +3002,9 @@ in `docs/settings.md` shows the commands.
|
||||
### Forwarded headers
|
||||
|
||||
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,
|
||||
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
|
||||
usual default for a trusted-proxy setting, the private ranges, covers it.
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
ARG SWW_IMAGE=smallwebwaf
|
||||
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.
|
||||
RUN nix-env -iA nixpkgs.busybox
|
||||
|
||||
|
||||
+2
-2
@@ -68,8 +68,8 @@ Every one of these lines has:
|
||||
### Request detail
|
||||
|
||||
- `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
|
||||
front of it.
|
||||
- `peer_ip`: the address of `rproxy`'s TCP peer, normally the TLS-terminating
|
||||
proxy.
|
||||
- `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
|
||||
/32 or an IPv6 group.
|
||||
|
||||
+3
-3
@@ -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
|
||||
`X-Forwarded-For` is believed. Set but empty, it trusts nothing. A client
|
||||
outside these netblocks cannot claim another address. Where other containers
|
||||
reach `rproxy` directly, or visitors on a private network reach the reverse
|
||||
proxy in front, they can name another address in the header. Narrowing the
|
||||
setting to that proxy's own network closes that gap.
|
||||
reach `rproxy` directly, or visitors on a private network reach the
|
||||
TLS-terminating proxy, they can name another address in the header. Narrowing
|
||||
the setting to the TLS-terminating proxy's own network closes that gap.
|
||||
|
||||
## The image's command
|
||||
|
||||
|
||||
@@ -20,8 +20,13 @@ main() {
|
||||
# the version a build stage derives from the .git in the context.
|
||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||
[ -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 \
|
||||
--build-arg VERSION="$version" \
|
||||
--build-arg REVISION="$revision" \
|
||||
--build-arg CREATED="$created" \
|
||||
-t "$("$SCRIPT_DIR/projectname")" .
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,13 @@ main() {
|
||||
# the version a build stage derives from the .git in the context.
|
||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||
[ -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 \
|
||||
--build-arg VERSION="$version" \
|
||||
--build-arg REVISION="$revision" \
|
||||
--build-arg CREATED="$created" \
|
||||
-t "$("$SCRIPT_DIR/projectname")" .
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user