Compare commits

..
Author SHA1 Message Date
sneak 784f3d2fb7 rproxy passes the app the client's AS number and country by default (closes #379)
check / check (push) Failing after 3m58s
SWW_WAF_ADD_LOOKUP_HEADERS now defaults to true, so the app gets
X-Client-ASN and X-Client-Country with no setting. While
SWW_WAF_LOOKUP_SOURCE is off it defaults to false, so turning lookups
off alone still starts; true set with the lookup off still stops the
start. With GeoJS, a new client's first request now waits for its
answer, up to SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS.

Model: opus-5-5
2026-11-13 19:57:48 +00:00
10 changed files with 95 additions and 335 deletions

No files matched your search

+27 -57
View File
@@ -10,11 +10,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. traefik routes to
reverse proxy that terminates TLS, such as traefik, nginx or Caddy. It asks it. It asks `waf` about each request, passes the allowed ones to the app, and
`waf` about each request, passes the allowed ones to the app, and passes the passes the app's answers back. It keeps nothing between requests and writes
app's answers back. It keeps nothing between requests and writes nothing to 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 +57,8 @@ 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 traefik, the reverse proxy in
`rproxy`. front of `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,48 +90,20 @@ 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 traefik routes 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.
Publish no port of the app or of `waf`. Anything on that network can reach the Point traefik's labels at port 8080 of `rproxy`
app around `rproxy`, and can ask `waf` questions, so the network holds only what (`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port
you trust. Each further app adds its own container and its own `rproxy`, whose of the app or of `waf`. Anything on that network can reach the app around
`rproxy`, and can ask `waf` questions, so the network holds only what you trust.
Each further app adds its own container and its own `rproxy`, whose
`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
`rproxy` serves plain HTTP. It runs behind any reverse proxy that terminates
TLS, such as traefik, nginx or Caddy. That 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;
- 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.
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:
```nginx
location / {
proxy_pass http://rproxy:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
### `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`
@@ -183,13 +154,13 @@ 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 sees the headers traefik sent, with traefik's address added to
address added to `X-Forwarded-For`, from a TCP peer that is `rproxy`. So the app `X-Forwarded-For`, from a TCP peer that is `rproxy`. So the app must trust
must trust `rproxy`'s address for forwarded headers. In a container of its own, `rproxy`'s address for forwarded headers. In a container of its own, `rproxy` is
`rproxy` is on the private network, which the usual default of a trusted-proxy on the private network, which the usual default of a trusted-proxy setting
setting covers. Beside the app, its address is `127.0.0.1` or `::1`, which the covers. Beside the app, its address is `127.0.0.1` or `::1`, which the private
private ranges leave out, so the app's own setting must name them too, as ranges leave out, so the app's own setting must name them too, as `app.run`
`app.run` does. does.
### Checking that it works ### Checking that it works
@@ -197,9 +168,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 traefik 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
@@ -569,10 +540,9 @@ request reaches the app.
A refused visitor is answered with `SWW_WAF_BAN_RESPONSE`. A `block` rule or a A refused visitor is answered with `SWW_WAF_BAN_RESPONSE`. A `block` rule or a
Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has
false positives; such refusals count toward `SWW_WAF_REFUSALS_LIMIT_PER_MINUTE` false positives; such refusals count toward `SWW_WAF_REFUSALS_LIMIT_PER_MINUTE`
instead. A request over the header limits of step 3 is answered `414` for a instead. A request over the header limits of step 3 is answered `431`, and bans
request line over 8 KiB and `431` otherwise. It bans nobody either. `rproxy` nobody either. `rproxy` itself answers `405` to a method it does not pass on,
itself answers `405` to a method it does not pass on, and the status for a size and the status for a size or time limit, such as `413` for a body too large.
or time limit, such as `413` for a body too large.
### Clients and bans ### Clients and bans
+47 -65
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 traefik 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,7 @@ 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 traefik's job.
proxy in front of `rproxy`, 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.
@@ -153,11 +151,11 @@ field gives that name. `refusal_limit` and `byte_limits` give theirs in
around its address, a /64 by default, since one abuser usually controls a around its address, a /64 by default, since one abuser usually controls a
whole /64. A client is therefore one IPv4 address or one IPv6 group. whole /64. A client is therefore one IPv4 address or one IPv6 group.
3. The header limits (`header_limits`; see "Answering in time"). They come first 3. The header limits (`header_limits`; see "Answering in time"). They come first
after `SWW_WAF_ALLOW_NETS`, since they only measure lines and count header after `SWW_WAF_ALLOW_NETS`, since they only count header fields and cookies.
fields and cookies. Next, the tag lists (`tag_lists`; see "Request tags"). Next, the tag lists (`tag_lists`; see "Request tags"). Each list that holds
Each list that holds the client adds its tag. Next, `routewatch_tags` adds the client adds its tag. Next, `routewatch_tags` adds the client's `asn=` and
the client's `asn=` and `prefix=` tags while `SWW_WAF_ROUTEWATCH_URL` is set. `prefix=` tags while `SWW_WAF_ROUTEWATCH_URL` is set. A client in
A client in `SWW_WAF_ALLOW_NETS` skips the header limits and gets no tag. `SWW_WAF_ALLOW_NETS` skips the header limits and gets no tag.
4. The static lists. A client in `SWW_WAF_ALLOW_NETS` skips steps 5 to 15, the 4. The static lists. A client in `SWW_WAF_ALLOW_NETS` skips steps 5 to 15, the
byte limits and the refusal limit. A client in `SWW_WAF_DENY_NETS` is refused byte limits and the refusal limit. A client in `SWW_WAF_DENY_NETS` is refused
(`deny_nets`); a client in both lists is let through. (`deny_nets`); a client in both lists is let through.
@@ -254,9 +252,9 @@ does not reach neighbours who did nothing. Requests refused under a ban are not
counted against limits. They are counted in the client's history and in the counted against limits. They are counted in the client's history and in the
ban's notes. ban's notes.
A request over the header limits is refused with `414` or `431` before the bans A request over the header limits is refused with `431` before the bans are
are checked (see "Answering in time"). It makes no ban, makes no ban permanent checked (see "Answering in time"). It makes no ban, makes no ban permanent and
and is not counted in a ban's notes. is not counted in a ban's notes.
An offence is a request `waf` holds against the client. It is one of: An offence is a request `waf` holds against the client. It is one of:
@@ -1945,10 +1943,10 @@ leave out a cookie only when its name matches exactly, case included.
### Ban lengths, response and scope ### Ban lengths, response and scope
`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. Behind traefik, `close`
the client unanswered: the reverse proxy in front answers `502`, as it does does not leave the client unanswered: traefik answers `502`, as it does whenever
whenever its backend drops a connection. `SWW_WAF_BAN_SCOPE_V4_PREFIX` widens an its backend drops a connection. `SWW_WAF_BAN_SCOPE_V4_PREFIX` widens an IPv4 ban
IPv4 ban to the surrounding netblock. The four ban lengths are under "Bans". to the surrounding netblock. The four ban lengths are under "Bans".
### Reputation ### Reputation
@@ -2383,7 +2381,7 @@ answer but the last below carries `X-Sww-Disposition`.
of: of:
- `SWW_WAF_BAN_RESPONSE`, for a refusal; - `SWW_WAF_BAN_RESPONSE`, for a refusal;
- `403`, for a `block` rule or the Core Rule Set; - `403`, for a `block` rule or the Core Rule Set;
- `414` or `431`, for a request over the header limits; - `431`, for a request over the header limits;
- `404`, for a path under `/_sww/` that passes the checks, so that the app - `404`, for a path under `/_sww/` that passes the checks, so that the app
never sees that prefix. never sees that prefix.
- `X-Sww-Disposition: close`, status `200`, no body. This takes the place of a - `X-Sww-Disposition: close`, status `200`, no body. This takes the place of a
@@ -2444,31 +2442,22 @@ never wait. These can still wait for GeoJS:
- a rule file; - a rule file;
- the Core Rule Set. - the Core Rule Set.
The header limits are fixed, with no setting. A line's length is in bytes and The header limits are fixed, with no setting. `waf` refuses a request over
does not count its line ending. `waf` refuses a request whose request line is either one:
longer than 8 KiB with `414`. An example is `GET /search?q=` followed by 30,000
characters.
It refuses a request with `431` for any of:
- a header line longer than 8 KiB. Each value of a header sent twice is a line
of its own. `Host` counts as a line.
- more than 50 cookies. Each part of its `Cookie` headers between semicolons - more than 50 cookies. Each part of its `Cookie` headers between semicolons
counts as one, unless it is empty or only spaces and tabs. counts as one, unless it is empty or only spaces and tabs.
- more than 100 header fields. A header sent twice counts twice. `Host` and - more than 100 header fields. A header sent twice counts twice. `Host` and
`Transfer-Encoding` do not count. `X-Request-ID` and `X-Forwarded-Proto` do. `Transfer-Encoding` do not count. `X-Request-ID` and `X-Forwarded-Proto` do.
Real clients rarely reach these limits. nginx refuses lines over 8 KiB by Real clients never reach these limits. The answer is `deny` with `431`, and the
default, and Apache's default limit is about the same. The answer is `deny`. The log line's action is `request_header_too_large`. The refusal makes no ban and is
log line's action is `request_line_too_long` for `414` and no offence.
`request_header_too_large` for `431`. The refusal makes no ban and is no
offence.
The header limits are the first check after `SWW_WAF_ALLOW_NETS`. A request over The header limits are the first check after `SWW_WAF_ALLOW_NETS`. A request over
them is refused before any later check runs. So it causes no lookup, no query to them is refused before any later check runs. So it causes no lookup, no query to
an outside source and no rule match, and the rate limits do not count it. A an outside source and no rule match, and the rate limits do not count it. A
client under a ban or in `SWW_WAF_DENY_NETS` also gets `414` or `431` for such a client under a ban or in `SWW_WAF_DENY_NETS` also gets `431` for such a request.
request.
The limits apply on every path and in every `SWW_WAF_CORE_RULE_SET_MODE`. A The limits apply on every path and in every `SWW_WAF_CORE_RULE_SET_MODE`. A
client in `SWW_WAF_ALLOW_NETS` skips them, as it skips the Core Rule Set. In client in `SWW_WAF_ALLOW_NETS` skips them, as it skips the Core Rule Set. In
@@ -2476,10 +2465,9 @@ client in `SWW_WAF_ALLOW_NETS` skips them, as it skips the Core Rule Set. In
other check runs on it. other check runs on it.
The Core Rule Set runs each of its rules on each cookie, so thousands of small The Core Rule Set runs each of its rules on each cookie, so thousands of small
cookies would keep it busy for over a second. One 30 KB cookie or query string cookies would keep it busy for over a second. Within the limits, one long cookie
would keep it busy for a third of a second or more. A request with several long or query string can still keep it busy for a fraction of a second. So enough
`Cookie` lines, each within the limits, can keep it as busy. Enough such such requests at once can overload `waf` past
requests at once can still overload `waf` past
`SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`. `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`.
### The report ### The report
@@ -2773,10 +2761,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. traefik, by default, closes an
close an unused connection sooner, so that it never sends a request on a unused connection after 90 seconds. So traefik closes first, and never sends a
connection `rproxy` is closing. traefik closes one after 90 seconds by default. request on a connection `rproxy` is closing.
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,16 +2814,14 @@ 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 traefik routes to, with `SWW_RPROXY_UPSTREAM_URL` naming the
`SWW_RPROXY_UPSTREAM_URL` naming the app and `SWW_RPROXY_WAF_BACKEND` naming 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.
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.
in `README.md` lists what that proxy must do.
### `rproxy` in the app's container ### `rproxy` in the app's container
@@ -2908,10 +2893,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, traefik answers `502`. While the app is down, `rproxy`
app is down, `rproxy` answers `502`, which is not a client offence. While answers `502`, which is not a client offence. While `waf` is down,
`waf` is down, `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides (see `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides (see "Without an answer
"Without an answer from `waf`"). 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,26 +2929,23 @@ 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 traefik's routes while `waf` is down.
is 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 traefik sends a container no requests until it shows as healthy. So the check
container no requests until it shows as healthy. So the check runs every second runs every second from the container's start until it first passes, for up to a
from the container's start until it first passes, for up to a minute, and every minute, and every 30 seconds after that. An app with a health check of its own
30 seconds after that. An app with a health check of its own can replace the can replace the image's `HEALTHCHECK` with one that checks both.
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 traefik routes to. It passes requests to the app at
app at `SWW_RPROXY_UPSTREAM_URL`, `http://127.0.0.1:8081` by default. Another `SWW_RPROXY_UPSTREAM_URL`, `http://127.0.0.1:8081` by default. Another port may
port may be set: the health check follows the setting, and the reverse proxy in be set: the health check follows the setting, and traefik's port label
front must name it, as traefik's port label (`traefik.http.services.<name>.loadbalancer.server.port`) must name it. An app
(`traefik.http.services.<name>.loadbalancer.server.port`) does. An app beside beside `rproxy` must leave that port free.
`rproxy` must leave that port free.
`waf` listens on no port but the one in `SWW_WAF_LISTEN_ADDR`, 8080 by default. `waf` listens on no port but the one in `SWW_WAF_LISTEN_ADDR`, 8080 by default.
The questions, the reports and its own endpoints all come there. The questions, the reports and its own endpoints all come there.
@@ -3004,9 +2986,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 traefik's address, which `rproxy` adds after the visitor's. So the app must
visitor's. So the app must trust `rproxy`'s address for forwarded headers, trust `rproxy`'s address for forwarded headers, besides the private address
besides the private address ranges that proxy is on. ranges traefik 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.
+3 -8
View File
@@ -68,8 +68,7 @@ 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 traefik.
front of it.
- `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.
@@ -103,8 +102,7 @@ Every one of these lines has:
ban this request made or made permanent; ban this request made or made permanent;
- `country_denied`, `client_is_rate_limited`, `blocked_by_block_rule` and - `country_denied`, `client_is_rate_limited`, `blocked_by_block_rule` and
`blocked_by_core_rule_set`: refused by that check; `blocked_by_core_rule_set`: refused by that check;
- `request_line_too_long`: a request line over the header limits; - `request_header_too_large`: over a header limit;
- `request_header_too_large`: headers over the header limits;
- `own_endpoint`: answered at one of `waf`'s own endpoints, or `404` for a - `own_endpoint`: answered at one of `waf`'s own endpoints, or `404` for a
path under `/_sww/`. 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
@@ -126,10 +124,7 @@ Every one of these lines has:
- `rate_limits`: `window`, `count` and `limit`; - `rate_limits`: `window`, `count` and `limit`;
- `trap_paths`: `path`, the trap path asked for; - `trap_paths`: `path`, the trap path asked for;
- `rule_files`: `rule_id` and `rule_action`, of the rule that refused it; - `rule_files`: `rule_id` and `rule_action`, of the rule that refused it;
- `header_limits`: for a request line, `request_line_bytes` and - `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`;
`request_line_bytes_limit`; for headers, `longest_header_line_bytes`,
`header_line_bytes_limit`, `fields`, `fields_limit`, `cookies` and
`cookies_limit`;
- `core_rule_set`: `score`, and `threshold`, - `core_rule_set`: `score`, and `threshold`,
`SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD`; `SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD`;
- `refusal_limit`: `window`, `count` and `limit`, of the refusal limit; - `refusal_limit`: `window`, `count` and `limit`, of the refusal limit;
+5 -6
View File
@@ -423,9 +423,8 @@ A few limits are fixed rather than settings:
- 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;
- `waf` holds at most 10,000 answered requests awaiting their reports; - `waf` holds at most 10,000 answered requests awaiting their reports;
- `waf` refuses a request with a request line or header line over 8 KiB, more - `waf` refuses a request with more than 50 cookies or more than 100 header
than 50 cookies or more than 100 header fields (see "Answering in time" under fields (see "Answering in time" under "`waf`" in [`SPEC.md`](../SPEC.md));
"`waf`" in [`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 `waf`'s answer, and answers `502` - `rproxy` reads at most 1 MiB of the body of `waf`'s answer, and answers `502`
@@ -478,9 +477,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 traefik, they
proxy in front, they can name another address in the header. Narrowing the can name another address in the header. Narrowing the setting to traefik's own
setting to that proxy's own network closes that gap. network closes that gap.
## The image's command ## The image's command
+2 -7
View File
@@ -21,12 +21,7 @@ const (
// the app. // the app.
ActionForward = "forward" ActionForward = "forward"
// ActionRequestLineTooLong is a request whose request line is too long for // ActionRequestHeaderTooLarge is a request over the header limits.
// the header limits.
ActionRequestLineTooLong = "request_line_too_long"
// ActionRequestHeaderTooLarge is a request whose headers are over the
// header limits.
ActionRequestHeaderTooLarge = "request_header_too_large" ActionRequestHeaderTooLarge = "request_header_too_large"
// ActionClientIsRateLimited is a request refused because it took its // ActionClientIsRateLimited is a request refused because it took its
@@ -148,7 +143,7 @@ type Line struct {
// taken with a request it would have refused: ActionClientDenied, // taken with a request it would have refused: ActionClientDenied,
// ActionTemporarilyBanned, ActionPermanentlyBanned, ActionCountryDenied, // ActionTemporarilyBanned, ActionPermanentlyBanned, ActionCountryDenied,
// ActionClientIsRateLimited, ActionBlockedByBlockRule, // ActionClientIsRateLimited, ActionBlockedByBlockRule,
// ActionBlockedByCoreRuleSet, or, in waf, ActionRequestLineTooLong or // ActionBlockedByCoreRuleSet, or, in waf,
// ActionRequestHeaderTooLarge for the header limits. // ActionRequestHeaderTooLarge for the header limits.
WouldAction string `json:"would_action,omitempty"` WouldAction string `json:"would_action,omitempty"`
// Heuristic is the name of the heuristic that refused the request, in // Heuristic is the name of the heuristic that refused the request, in
+1 -130
View File
@@ -39,7 +39,6 @@ const (
// The header limits SPEC.md gives. // The header limits SPEC.md gives.
cookieLimit = 50 cookieLimit = 50
headerFieldLimit = 100 headerFieldLimit = 100
lineLimit = 8192
// cookieHeader is the header the cookie limit counts. // cookieHeader is the header the cookie limit counts.
cookieHeader = "Cookie" cookieHeader = "Cookie"
// tooLarge is how waf refuses a request over the header limits. // tooLarge is how waf refuses a request over the header limits.
@@ -105,139 +104,11 @@ func TestHeaderLimitRefusalNamesItsHeuristicCountsAndLimits(t *testing.T) {
fillHeaders(req, headerFieldLimit+1) fillHeaders(req, headerFieldLimit+1)
do(t, req) do(t, req)
// The longest header line is X-Request-ID's, with its 26-character id.
wantHeuristic( wantHeuristic(
t, t,
s.out.requestLine(t), s.out.requestLine(t),
"header_limits", "header_limits",
`{"longest_header_line_bytes": 40, "header_line_bytes_limit": 8192, `{"fields": 101, "fields_limit": 100, "cookies": 0, "cookies_limit": 50}`,
"fields": 101, "fields_limit": 100, "cookies": 0, "cookies_limit": 50}`,
)
}
// longQuery returns a path whose request line, sent by Go's client as
// "GET <path> HTTP/1.1", is lineBytes long.
func longQuery(lineBytes int) string {
const around = len("GET /?q= HTTP/1.1")
return "/?q=" + strings.Repeat("a", lineBytes-around)
}
func TestARequestLineOverTheLimitIsRefusedWith414BeforeAnyOtherCheck(
t *testing.T,
) {
t.Parallel()
for _, tc := range []struct {
name string
lineBytes int
// cookies is how many cookies the request carries.
cookies int
status int
action string
}{
{
"at the limit", lineLimit, 0, http.StatusOK,
requestlog.ActionForward,
},
{
"over the limit", lineLimit + 1, 0, http.StatusRequestURITooLong,
requestlog.ActionRequestLineTooLong,
},
{
"over the limit and the cookie limit", lineLimit + 1,
cookieLimit + 1, http.StatusRequestURITooLong,
requestlog.ActionRequestLineTooLong,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := startWAF(t, "", map[string]string{coreRuleSetMode: block})
req := s.question(siteA, client, longQuery(tc.lineBytes))
if tc.cookies > 0 {
req.Header.Set(cookieHeader, cookies(tc.cookies))
}
got := do(t, req)
line := s.out.requestLine(t)
if tc.status == http.StatusOK {
wantDisposition(t, got, tc.status, waf.DispositionAllow)
} else {
wantDisposition(t, got, tc.status, waf.DispositionDeny)
}
wantLine(t, line, tc.status, tc.action)
refused := tc.status != http.StatusOK
if refused != (line.DurationCoreRuleSetMS == nil) {
t.Errorf(
"Core Rule Set time %v for a request refused: %t",
line.DurationCoreRuleSetMS,
refused,
)
}
})
}
}
func TestAHeaderLineOverTheLimitIsRefusedWith431(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
make func(*http.Request)
// refused is true for a request over the limit, and false for one at it.
refused bool
}{
{"a header line at the limit", func(req *http.Request) {
req.Header.Set("X-Long", strings.Repeat("a", lineLimit-len("X-Long: ")))
}, false},
{"a header line over the limit", func(req *http.Request) {
req.Header.Set("X-Long", strings.Repeat("a", lineLimit+1-len("X-Long: ")))
}, true},
{"one cookie over the limit", func(req *http.Request) {
req.Header.Set(cookieHeader, "a="+strings.Repeat("a", lineLimit))
}, true},
{"a Host line over the limit", func(req *http.Request) {
req.Host = strings.Repeat("a", lineLimit+1-len("Host: "))
}, true},
{"a header sent twice, each line within the limit", func(req *http.Request) {
req.Header.Add("X-Long", strings.Repeat("a", 8000))
req.Header.Add("X-Long", strings.Repeat("a", 8000))
}, false},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := startWAF(t, "", nil)
req := s.question(siteA, client, "/")
tc.make(req)
got := do(t, req)
if tc.refused {
wantDisposition(t, got, tooLarge, waf.DispositionDeny)
} else {
wantDisposition(t, got, http.StatusOK, waf.DispositionAllow)
}
})
}
}
func TestRequestLineRefusalNamesItsHeuristicLengthAndLimit(t *testing.T) {
t.Parallel()
s := startWAF(t, "", nil)
do(t, s.question(siteA, client, longQuery(lineLimit+1)))
wantHeuristic(
t,
s.out.requestLine(t),
"header_limits",
`{"request_line_bytes": 8193, "request_line_bytes_limit": 8192}`,
) )
} }
+6 -51
View File
@@ -10,22 +10,16 @@ import (
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
// The header limits: the most cookies and header fields a request may have, // The header limits: the most cookies and header fields a request may have.
// and the longest its request line and each header line may be, in bytes, not // Real clients never reach them.
// counting the line ending. Real clients rarely reach them. maxLineBytes is
// what nginx allows by default.
const ( const (
maxCookies = 50 maxCookies = 50
maxHeaderFields = 100 maxHeaderFields = 100
maxLineBytes = 8 << 10
) )
// headerLimits refuses a request with a request line longer than maxLineBytes, // headerLimits refuses a request with more than maxHeaderFields header fields
// with 414. It refuses a request with a header line longer than maxLineBytes, // or more than maxCookies cookies, with 431. Its figures are the request's
// more than maxHeaderFields header fields or more than maxCookies cookies, with // header fields and cookies, and the limit of each.
// 431. Its figures are the request line's length and its limit for 414. For
// 431 they are the longest header line, the header fields and the cookies,
// each with its limit.
type headerLimits struct{} type headerLimits struct{}
func (headerLimits) name() string { return "header_limits" } func (headerLimits) name() string { return "header_limits" }
@@ -35,59 +29,20 @@ func (headerLimits) check(
rq *request, rq *request,
_ time.Time, _ time.Time,
) *verdict { ) *verdict {
requestLine := rq.requestLineBytes()
if requestLine > maxLineBytes {
return &verdict{
action: requestlog.ActionRequestLineTooLong,
figures: map[string]any{
"request_line_bytes": requestLine,
"request_line_bytes_limit": maxLineBytes,
},
}
}
longest := rq.longestHeaderLineBytes()
fields, cookies := rq.headerCounts() fields, cookies := rq.headerCounts()
if fields <= maxHeaderFields && cookies <= maxCookies {
if longest <= maxLineBytes && fields <= maxHeaderFields &&
cookies <= maxCookies {
return nil return nil
} }
return &verdict{ return &verdict{
action: requestlog.ActionRequestHeaderTooLarge, action: requestlog.ActionRequestHeaderTooLarge,
figures: map[string]any{ figures: map[string]any{
"longest_header_line_bytes": longest,
"header_line_bytes_limit": maxLineBytes,
"fields": fields, "fields_limit": maxHeaderFields, "fields": fields, "fields_limit": maxHeaderFields,
"cookies": cookies, "cookies_limit": maxCookies, "cookies": cookies, "cookies_limit": maxCookies,
}, },
} }
} }
// requestLineBytes returns the length of the request's request line, such as
// "GET /search?q=x HTTP/1.1", not counting its line ending. The target is the
// one the visitor sent, which rproxy's question carries unchanged.
func (rq *request) requestLineBytes() int {
return len(rq.in.Method) + len(" ") + len(rq.in.RequestURI) +
len(" ") + len(rq.in.Proto)
}
// longestHeaderLineBytes returns the length of the request's longest header
// line, such as "Cookie: a=1", not counting its line ending. Each value of a
// header sent twice is a line of its own, and Host is a line too.
func (rq *request) longestHeaderLineBytes() int {
longest := len("Host: ") + len(rq.in.Host)
for name, values := range rq.in.Header {
for _, value := range values {
longest = max(longest, len(name)+len(": ")+len(value))
}
}
return longest
}
// headerCounts returns the request's header fields, each value of a header sent // headerCounts returns the request's header fields, each value of a header sent
// twice being one, and its cookies, each part of a Cookie header between // twice being one, and its cookies, each part of a Cookie header between
// semicolons being one unless it is empty or only spaces and tabs. The Core // semicolons being one unless it is empty or only spaces and tabs. The Core
+2 -2
View File
@@ -41,8 +41,8 @@ const (
// beforeLookup are the heuristics that run before the client's AS number and // beforeLookup are the heuristics that run before the client's AS number and
// country are looked up, so that a client they refuse is not looked up. // country are looked up, so that a client they refuse is not looked up.
// header_limits comes first: it only measures and counts, and a request it // header_limits comes first: it only counts, and a request it refuses causes no
// refuses causes no other work. // other work.
func beforeLookup() []heuristic { func beforeLookup() []heuristic {
return []heuristic{ return []heuristic{
headerLimits{}, headerLimits{},
+1 -3
View File
@@ -177,7 +177,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 heuristics of checkClient decide: a request they // the request through. The heuristics of checkClient decide: a request they
// refuse is answered with SWW_WAF_BAN_RESPONSE, 403 for a block rule or the Core // refuse is answered with SWW_WAF_BAN_RESPONSE, 403 for a block rule or the Core
// Rule Set, or 414 or 431 for the header limits. In observe mode nothing is refused, // Rule Set, 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 // and the log line names what enforce mode would have done. ctx is the
// request's own context. // request's own context.
func (rq *request) check(ctx context.Context) *refusal { func (rq *request) check(ctx context.Context) *refusal {
@@ -196,8 +196,6 @@ func (rq *request) check(ctx context.Context) *refusal {
case action == requestlog.ActionBlockedByBlockRule || case action == requestlog.ActionBlockedByBlockRule ||
action == requestlog.ActionBlockedByCoreRuleSet: action == requestlog.ActionBlockedByCoreRuleSet:
return &refusal{status: http.StatusForbidden, action: action} return &refusal{status: http.StatusForbidden, action: action}
case action == requestlog.ActionRequestLineTooLong:
return &refusal{status: http.StatusRequestURITooLong, action: action}
case action == requestlog.ActionRequestHeaderTooLarge: case action == requestlog.ActionRequestHeaderTooLarge:
return &refusal{ return &refusal{
status: http.StatusRequestHeaderFieldsTooLarge, status: http.StatusRequestHeaderFieldsTooLarge,
+1 -6
View File
@@ -1690,12 +1690,7 @@ func TestALargeBodyAnnouncedAndLongHeadersAreNoReasonToRefuse(t *testing.T) {
strings.NewReader("a body")) strings.NewReader("a body"))
req.Header.Set(waf.ContentLengthHeader, "1073741824") req.Header.Set(waf.ContentLengthHeader, "1073741824")
req.Header.Set(forwardedFor, otherClient) req.Header.Set(forwardedFor, otherClient)
req.Header.Set("X-Long", strings.Repeat("x", 40<<10))
// 40,000 bytes of headers, over rproxy's 32 KiB, in lines within the
// header limits.
for i := range 5 {
req.Header.Set("X-Long-"+strconv.Itoa(i), strings.Repeat("x", 8000))
}
got := do(t, req) got := do(t, req)
wantDisposition(t, got, http.StatusOK, waf.DispositionAllow) wantDisposition(t, got, http.StatusOK, waf.DispositionAllow)