Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37419716ea | ||
|
|
8efd6339a6 | ||
|
|
89a60cf9fc | ||
|
|
a05a82b1af | ||
|
|
53d9bf6af6 | ||
|
|
3550956cbb |
No files matched your search
@@ -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,10 +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. traefik routes to
|
- `sww rproxy` is a small reverse proxy in front of one app. It sits behind a
|
||||||
it. It asks `waf` about each request, passes the allowed ones to the app, and
|
TLS-terminating proxy, such as traefik, nginx or Caddy. It asks `waf` about
|
||||||
passes the app's answers back. It keeps nothing between requests and writes
|
each request, passes the allowed ones to the app, and passes the app's answers
|
||||||
nothing to disk.
|
back. It writes nothing to 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
|
||||||
@@ -22,7 +35,26 @@ shipped in one image, with two commands that do this together:
|
|||||||
The site keeps answering while `waf` is down, slow or restarting: without an
|
The site keeps answering while `waf` is down, slow or restarting: without an
|
||||||
answer in time, `rproxy` passes the request to the app.
|
answer in time, `rproxy` passes the request to the app.
|
||||||
|
|
||||||
It protects against:
|
`rproxy` also protects the app on its own, whether or not `waf` answers. With
|
||||||
|
the default settings, `rproxy`:
|
||||||
|
|
||||||
|
- answers `400` to a malformed request, such as a broken header line or a
|
||||||
|
missing `Host`;
|
||||||
|
- answers `405` to a method it does not pass on, such as `TRACE`;
|
||||||
|
- answers `431` to a request line and headers over 32 KiB together;
|
||||||
|
- gives the visitor 60 seconds to send the request line and headers, and then 60
|
||||||
|
seconds more for the whole request to reach the app;
|
||||||
|
- answers `413` to a request body over 100 MiB;
|
||||||
|
- gives the app's answer 30 minutes to reach the visitor, counted from the end
|
||||||
|
of the request;
|
||||||
|
- answers `502` in place of an app's answer whose body is over 1 GiB, or cuts it
|
||||||
|
off if it has started;
|
||||||
|
- closes a kept-open connection after 120 seconds idle.
|
||||||
|
|
||||||
|
`rproxy` does not limit the number of headers. `waf` limits the number of
|
||||||
|
headers and cookies, and the length of the request line and of each header line.
|
||||||
|
|
||||||
|
Together, `rproxy` and `waf` protect against:
|
||||||
|
|
||||||
- Scrapers that fetch every page of a site, all day. Each client gets a limit on
|
- Scrapers that fetch every page of a site, all day. Each client gets a limit on
|
||||||
requests and on body bytes per minute, per hour and per day. The defaults
|
requests and on body bytes per minute, per hour and per day. The defaults
|
||||||
@@ -44,8 +76,6 @@ It protects against:
|
|||||||
limit, so that they are banned sooner;
|
limit, so that they are banned sooner;
|
||||||
- refuse or limit clients on a blocklist, a DNS blocklist, or with a high
|
- refuse or limit clients on a blocklist, a DNS blocklist, or with a high
|
||||||
AbuseIPDB score.
|
AbuseIPDB score.
|
||||||
- Slow and oversized requests. Every request and every answer has a time limit
|
|
||||||
and a size limit.
|
|
||||||
|
|
||||||
For the operator, it:
|
For the operator, it:
|
||||||
|
|
||||||
@@ -57,8 +87,7 @@ For the operator, it:
|
|||||||
|
|
||||||
Not built, and not planned:
|
Not built, and not planned:
|
||||||
|
|
||||||
- TLS and routing by host name. Both stay with traefik, the reverse proxy in
|
- TLS and routing by host name. Both stay with the TLS-terminating proxy.
|
||||||
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.
|
||||||
@@ -90,20 +119,61 @@ 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 traefik routes to:
|
- `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
|
`SWW_RPROXY_WAF_BACKEND` names `waf` and `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.
|
||||||
|
|
||||||
Point traefik's labels at port 8080 of `rproxy`
|
Publish no port of the app or of `waf`. Anything on that network can reach the
|
||||||
(`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port
|
app around `rproxy`, and can ask `waf` questions, so the network holds only what
|
||||||
of the app or of `waf`. Anything on that network can reach the app around
|
you trust. Each further app adds its own container and its own `rproxy`, whose
|
||||||
`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 TLS-terminating proxy
|
||||||
|
|
||||||
|
`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
|
||||||
|
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.
|
||||||
|
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 / {
|
||||||
|
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`
|
||||||
@@ -154,13 +224,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 traefik sent, with traefik's address added to
|
The app's TCP peer is `rproxy`. The app sees the headers the TLS-terminating
|
||||||
`X-Forwarded-For`, from a TCP peer that is `rproxy`. So the app must trust
|
proxy sent, with that proxy's address added to `X-Forwarded-For`. So the app
|
||||||
`rproxy`'s address for forwarded headers. In a container of its own, `rproxy` is
|
must trust `rproxy`'s address for forwarded headers. In a container of its own,
|
||||||
on the private network, which the usual default of a trusted-proxy setting
|
`rproxy` is on the private network, which the usual default of a trusted-proxy
|
||||||
covers. Beside the app, its address is `127.0.0.1` or `::1`, which the private
|
setting covers. Beside the app, its address is `127.0.0.1` or `::1`, which the
|
||||||
ranges leave out, so the app's own setting must name them too, as `app.run`
|
private ranges leave out, so the app's own setting must name them too, as
|
||||||
does.
|
`app.run` does.
|
||||||
|
|
||||||
### Checking that it works
|
### Checking that it works
|
||||||
|
|
||||||
@@ -168,7 +238,7 @@ does.
|
|||||||
`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 traefik gets the app's answer.
|
- 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
|
`https://<site>/_sww/healthz` answers `ok`. `waf`'s log lines about 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
|
||||||
@@ -228,7 +298,7 @@ Every setting the code reads, with its default:
|
|||||||
| `SWW_RPROXY_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
| `SWW_RPROXY_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
||||||
| `SWW_RPROXY_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. |
|
| `SWW_RPROXY_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. |
|
||||||
| `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `waf`'s answer to a question and to a report, in milliseconds. |
|
| `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `waf`'s answer to a question and to a report, in milliseconds. |
|
||||||
| `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` | `allow` | What `rproxy` does with a request when `waf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
|
| `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` | `allow` | What `rproxy` does with a request when `waf` gives no answer in time, and while the circuit breaker for asks to `waf` is open: `allow` passes it to the app, `deny` answers `503`. |
|
||||||
| `SWW_RPROXY_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
|
| `SWW_RPROXY_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
|
||||||
| `SWW_RPROXY_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. |
|
| `SWW_RPROXY_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`. The visitor gets `502` in place of a larger one, or it is cut off if it has already started. |
|
||||||
| `SWW_RPROXY_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. |
|
| `SWW_RPROXY_MAX_REQUEST_TIME_MS` | `60000` | Two time limits, each this long, in milliseconds or `off`: one for the visitor to send its request line and headers, then one for the whole request to reach the app. |
|
||||||
@@ -260,7 +330,7 @@ Every setting the code reads, with its default:
|
|||||||
| `SWW_WAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
|
| `SWW_WAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
|
||||||
| `SWW_WAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
| `SWW_WAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
||||||
| `SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS` | `800` | 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, in milliseconds. |
|
| `SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS` | `800` | 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, in milliseconds. |
|
||||||
| `SWW_WAF_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
| `SWW_WAF_ADD_LOOKUP_HEADERS` | `true`, or `false` while the lookup source is `off` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
||||||
| `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
| `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
||||||
| `SWW_WAF_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
|
| `SWW_WAF_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
|
||||||
| `SWW_WAF_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
|
| `SWW_WAF_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
|
||||||
|
|||||||
@@ -3,7 +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 traefik and one app.
|
- `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.
|
- `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
|
||||||
@@ -45,7 +45,8 @@ never as a time.
|
|||||||
|
|
||||||
## Non-goals
|
## Non-goals
|
||||||
|
|
||||||
- TLS, certificates and routing by host name. Those are traefik's job.
|
- 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.
|
- 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.
|
||||||
@@ -161,16 +162,16 @@ field gives that name. `refusal_limit` and `byte_limits` give theirs in
|
|||||||
(`deny_nets`); a client in both lists is let through.
|
(`deny_nets`); a client in both lists is let through.
|
||||||
5. Bans. A client under a ban is refused (`active_ban`; see "Bans").
|
5. Bans. A client under a ban is refused (`active_ban`; see "Bans").
|
||||||
6. The lookup, unless `SWW_WAF_LOOKUP_SOURCE` is `off` (see "Lookup of AS number
|
6. The lookup, unless `SWW_WAF_LOOKUP_SOURCE` is `off` (see "Lookup of AS number
|
||||||
and country"). The request goes on at once. It waits for the first GeoJS
|
and country"). The request waits for the first GeoJS answer, up to
|
||||||
answer, up to `SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS`, only when one of
|
`SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS`, when one of these needs it:
|
||||||
these needs it:
|
- `SWW_WAF_ADD_LOOKUP_HEADERS`, which is `true` by default;
|
||||||
- the country list;
|
- the country list;
|
||||||
- a biased threshold;
|
- a biased threshold;
|
||||||
- `SWW_WAF_TAG_DENY` or `SWW_WAF_TAG_LIMIT_PERCENT` naming an `asn=` or
|
- `SWW_WAF_TAG_DENY` or `SWW_WAF_TAG_LIMIT_PERCENT` naming an `asn=` or
|
||||||
`country=` tag;
|
`country=` tag.
|
||||||
- `SWW_WAF_ADD_LOOKUP_HEADERS`.
|
|
||||||
|
|
||||||
A request that ends before the answer comes is logged without it.
|
Otherwise the request goes on at once. A request that ends before the answer
|
||||||
|
comes is logged without it.
|
||||||
|
|
||||||
7. The country list (`countries`; see "Country list").
|
7. The country list (`countries`; see "Country list").
|
||||||
8. The reputation sources (see "Reputation"), in this order:
|
8. The reputation sources (see "Reputation"), in this order:
|
||||||
@@ -197,10 +198,10 @@ field gives that name. `refusal_limit` and `byte_limits` give theirs in
|
|||||||
16. A path under `/_sww/` is answered `404`, so that the app never sees that
|
16. A path under `/_sww/` is answered `404`, so that the app never sees that
|
||||||
prefix.
|
prefix.
|
||||||
17. `rproxy` passes the request to `SWW_RPROXY_UPSTREAM_URL`, streaming (see
|
17. `rproxy` passes the request to `SWW_RPROXY_UPSTREAM_URL`, streaming (see
|
||||||
"Passing a request to the app"). With `SWW_WAF_ADD_LOOKUP_HEADERS`,
|
"Passing a request to the app"). While `SWW_WAF_ADD_LOOKUP_HEADERS` is
|
||||||
`X-Client-ASN` and `X-Client-Country` are added for the app's own logs; a
|
`true`, as by default, `X-Client-ASN` and `X-Client-Country` are added for
|
||||||
header whose value is unknown is left out. Whatever that setting says, any
|
the app's own logs; a header whose value is unknown is left out. Whatever
|
||||||
such header the client sent is removed.
|
that setting says, any such header the client sent is removed.
|
||||||
18. After the visitor's answer has ended, `rproxy` sends the report (see "The
|
18. After the visitor's answer has ended, `rproxy` sends the report (see "The
|
||||||
report"). `waf` then counts:
|
report"). `waf` then counts:
|
||||||
- For a request it allowed, the body bytes of the response, of the request
|
- For a request it allowed, the body bytes of the response, of the request
|
||||||
@@ -1943,10 +1944,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. Behind traefik, `close`
|
when debugging; `429` and `close` are the alternatives. `close` does not leave
|
||||||
does not leave the client unanswered: traefik answers `502`, as it does whenever
|
the client unanswered: the TLS-terminating proxy answers `502`, as it does
|
||||||
its backend drops a connection. `SWW_WAF_BAN_SCOPE_V4_PREFIX` widens an IPv4 ban
|
whenever its backend drops a connection. `SWW_WAF_BAN_SCOPE_V4_PREFIX` widens an
|
||||||
to the surrounding netblock. The four ban lengths are under "Bans".
|
IPv4 ban to the surrounding netblock. The four ban lengths are under "Bans".
|
||||||
|
|
||||||
### Reputation
|
### Reputation
|
||||||
|
|
||||||
@@ -2373,9 +2374,9 @@ runs no check, writes no request log line and counts nothing in the metrics.
|
|||||||
answer but the last below carries `X-Sww-Disposition`.
|
answer but the last below carries `X-Sww-Disposition`.
|
||||||
|
|
||||||
- `X-Sww-Disposition: allow`, status `200`, no body. `rproxy` passes the request
|
- `X-Sww-Disposition: allow`, status `200`, no body. `rproxy` passes the request
|
||||||
to the app. With `SWW_WAF_ADD_LOOKUP_HEADERS` the answer also carries
|
to the app. While `SWW_WAF_ADD_LOOKUP_HEADERS` is `true`, the answer also
|
||||||
`X-Client-ASN` and `X-Client-Country`, which `rproxy` adds to the request it
|
carries `X-Client-ASN` and `X-Client-Country`, which `rproxy` adds to the
|
||||||
passes on. A header whose value is unknown is left out.
|
request it passes on. A header whose value is unknown is left out.
|
||||||
- `X-Sww-Disposition: deny`, with the status, headers and body the visitor is to
|
- `X-Sww-Disposition: deny`, with the status, headers and body the visitor is to
|
||||||
get. `rproxy` gives them to the visitor, less this header. The status is one
|
get. `rproxy` gives them to the visitor, less this header. The status is one
|
||||||
of:
|
of:
|
||||||
@@ -2582,11 +2583,11 @@ request and ban notes name the site by its host.
|
|||||||
## `rproxy`
|
## `rproxy`
|
||||||
|
|
||||||
`sww rproxy` is the reverse proxy in front of one app. It asks `waf` about each
|
`sww rproxy` is the reverse proxy in front of one app. It asks `waf` about each
|
||||||
request, and passes on what `waf` allows. It keeps nothing between requests and
|
request, and passes on what `waf` allows. It writes nothing to disk. The only
|
||||||
writes nothing to disk. When `waf` cannot be connected to, does not answer at
|
thing it keeps between requests is its circuit breaker for asks to `waf`.
|
||||||
all, or does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`,
|
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides what is done with a
|
||||||
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides. Its default, `allow`,
|
request when `waf` gives no answer in time, and while the circuit breaker is
|
||||||
keeps the site answering.
|
open. Its default, `allow`, keeps the site answering.
|
||||||
|
|
||||||
`rproxy` reads only its `SWW_RPROXY_` settings, which `docs/settings.md` lists.
|
`rproxy` reads only its `SWW_RPROXY_` settings, which `docs/settings.md` lists.
|
||||||
One of them is required: `SWW_RPROXY_WAF_BACKEND`, `waf`'s URL. It has no
|
One of them is required: `SWW_RPROXY_WAF_BACKEND`, `waf`'s URL. It has no
|
||||||
@@ -2604,7 +2605,8 @@ only. The image runs it by default (see "Deployment").
|
|||||||
4. It refuses a request with a method it does not pass on, or one it cannot put
|
4. It refuses a request with a method it does not pass on, or one it cannot put
|
||||||
to `waf` as the visitor sent it (see "Requests `rproxy` refuses itself").
|
to `waf` as the visitor sent it (see "Requests `rproxy` refuses itself").
|
||||||
5. It reads up to the first 64 KiB of the body into memory.
|
5. It reads up to the first 64 KiB of the body into memory.
|
||||||
6. It sends `waf` the question (see "The question"). It speaks HTTP/1.1 to
|
6. It sends `waf` the question (see "The question"), unless the circuit breaker
|
||||||
|
keeps it from asking (see "The circuit breaker"). It speaks HTTP/1.1 to
|
||||||
`waf`, whatever `waf` offers. Go's HTTP/2 client would refuse to send a
|
`waf`, whatever `waf` offers. Go's HTTP/2 client would refuse to send a
|
||||||
visitor's `Upgrade` header, and most `Connection` headers. The question never
|
visitor's `Upgrade` header, and most `Connection` headers. The question never
|
||||||
carries the visitor's `Trailer`.
|
carries the visitor's `Trailer`.
|
||||||
@@ -2678,23 +2680,24 @@ log line has status `0`.
|
|||||||
### Without an answer from `waf`
|
### Without an answer from `waf`
|
||||||
|
|
||||||
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides what is done with the
|
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides what is done with the
|
||||||
request only when `waf`:
|
request when `waf`:
|
||||||
|
|
||||||
- cannot be connected to;
|
- cannot be connected to;
|
||||||
- does not answer at all;
|
- does not answer at all;
|
||||||
- does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, which covers
|
- does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, which covers
|
||||||
connecting, sending the question and reading the whole answer.
|
connecting, sending the question and reading the whole answer.
|
||||||
|
|
||||||
An answer whose body breaks off counts as no answer at all.
|
An answer whose body breaks off counts as no answer at all. The setting also
|
||||||
|
decides, without asking `waf`, while the circuit breaker is open.
|
||||||
|
|
||||||
`allow`, the default, passes the request to the app. `deny` answers `503`.
|
`allow`, the default, passes the request to the app. `deny` answers `503`.
|
||||||
Either way `rproxy` writes a process warning, its log line has
|
Either way `rproxy` writes a process warning, its log line has
|
||||||
`waf_backend_no_answer`, and it sends no report.
|
`waf_backend_no_answer`, and it sends no report.
|
||||||
|
|
||||||
A visitor can make `waf` slow. A request that keeps `waf` busy past
|
`waf` taking longer than `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS` to decide a request
|
||||||
`SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`, like any request while `waf` is overloaded,
|
is a bug in `waf`. Such a request gets
|
||||||
gets `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION`. With `deny`, such a request
|
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION`, like any request while `waf` is
|
||||||
gets `503` instead of reaching the app.
|
overloaded. With `deny`, it gets `503` instead of reaching the app.
|
||||||
|
|
||||||
A path under `/_sww/` that `allow` would pass on is answered `404` instead, so
|
A path under `/_sww/` that `allow` would pass on is answered `404` instead, so
|
||||||
that the app never sees that prefix.
|
that the app never sees that prefix.
|
||||||
@@ -2709,6 +2712,28 @@ A visitor that goes away while `waf` is being asked is no failure of `waf`.
|
|||||||
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` does not apply. Its log line has
|
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` does not apply. Its log line has
|
||||||
status `0` and no `disposition`.
|
status `0` and no `disposition`.
|
||||||
|
|
||||||
|
### The circuit breaker
|
||||||
|
|
||||||
|
`rproxy` stops asking `waf` for a while when `waf` keeps failing to answer. An
|
||||||
|
ask fails when `waf` gives no answer in time (see "Without an answer from
|
||||||
|
`waf`"), or an answer `rproxy` cannot pass on. Any other answer in time
|
||||||
|
succeeds, whatever it decides. A visitor that goes away while `waf` is being
|
||||||
|
asked counts as neither.
|
||||||
|
|
||||||
|
The breaker works like this:
|
||||||
|
|
||||||
|
- It opens when 5 asks fail within 5 seconds. Fewer failures never open it.
|
||||||
|
- While it is open, a request gets
|
||||||
|
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` at once, without asking `waf`.
|
||||||
|
It gets the same process warning and `waf_backend_no_answer` as a request
|
||||||
|
whose ask failed.
|
||||||
|
- While it is open, one request a second still asks `waf`.
|
||||||
|
- Each ask that succeeds clears the failures counted so far, and closes the
|
||||||
|
breaker if it is open.
|
||||||
|
|
||||||
|
The breaker lives in memory, and a restart of `rproxy` closes it. It has no
|
||||||
|
settings.
|
||||||
|
|
||||||
### Passing a request to the app
|
### Passing a request to the app
|
||||||
|
|
||||||
`rproxy` sends the app at `SWW_RPROXY_UPSTREAM_URL` the visitor's request: the
|
`rproxy` sends the app at `SWW_RPROXY_UPSTREAM_URL` the visitor's request: the
|
||||||
@@ -2771,9 +2796,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. traefik, by default, closes an
|
after 120 seconds idle. Neither has a setting. The TLS-terminating proxy should
|
||||||
unused connection after 90 seconds. So traefik closes first, and never sends a
|
close an unused connection sooner, so that it never sends a request on a
|
||||||
request on a connection `rproxy` is closing.
|
connection `rproxy` is closing. traefik closes one after 90 seconds by default.
|
||||||
|
|
||||||
What the visitor gets when a limit is passed:
|
What the visitor gets when a limit is passed:
|
||||||
|
|
||||||
@@ -2824,14 +2849,16 @@ 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 traefik routes to, with `SWW_RPROXY_UPSTREAM_URL` naming the
|
- `rproxy`, which the TLS-terminating proxy forwards to, with
|
||||||
app and `SWW_RPROXY_WAF_BACKEND` naming `waf`;
|
`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.
|
- `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.
|
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
|
### `rproxy` in the app's container
|
||||||
|
|
||||||
@@ -2903,10 +2930,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, traefik answers `502`. While the app is down, `rproxy`
|
- While `rproxy` is down, the TLS-terminating proxy answers `502`. While the app
|
||||||
answers `502`, which is not a client offence. While `waf` is down,
|
is down, `rproxy` answers `502`, which is not a client offence. While `waf` is
|
||||||
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides (see "Without an answer
|
down, `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` decides (see "Without an
|
||||||
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;
|
||||||
@@ -2939,23 +2966,26 @@ 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 traefik's routes 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
|
- With any other value, it fails with a message naming `SWW_COMMAND` and its
|
||||||
value.
|
value.
|
||||||
|
|
||||||
traefik sends a container no requests until it shows as healthy. So the check
|
A TLS-terminating proxy that reads Docker's health status, as traefik does,
|
||||||
runs every second from the container's start until it first passes, for up to a
|
sends a container no requests until it shows as healthy. So the check runs every
|
||||||
minute, and every 30 seconds after that. An app with a health check of its own
|
second from the container's start until it first passes, for up to a minute, and
|
||||||
can replace the image's `HEALTHCHECK` with one that checks both.
|
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
|
### 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 traefik routes to. It passes requests to the app at
|
default, which the TLS-terminating proxy forwards to. It passes requests to the
|
||||||
`SWW_RPROXY_UPSTREAM_URL`, `http://127.0.0.1:8081` by default. Another port may
|
app at `SWW_RPROXY_UPSTREAM_URL`, `http://127.0.0.1:8081` by default. Another
|
||||||
be set: the health check follows the setting, and traefik's port label
|
port may be set: the health check follows the setting, and the TLS-terminating
|
||||||
(`traefik.http.services.<name>.loadbalancer.server.port`) must name it. An app
|
proxy must name it, as traefik's port label
|
||||||
beside `rproxy` must leave that port free.
|
(`traefik.http.services.<name>.loadbalancer.server.port`) does. An app beside
|
||||||
|
`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.
|
||||||
@@ -2977,8 +3007,8 @@ host directory, keeps them across deploys. A host directory mounted there needs
|
|||||||
no change of owner, and files an earlier owner left in it can be read and
|
no change of owner, and files an earlier owner left in it can be read and
|
||||||
replaced. The volume holds a few tens of MiB at most with the defaults, and
|
replaced. The volume holds a few tens of MiB at most with the defaults, and
|
||||||
needs no backup beyond the host's own. The image declares no volume, since every
|
needs no backup beyond the host's own. The image declares no volume, since every
|
||||||
app image built on it would inherit it. `rproxy` keeps nothing, and needs no
|
app image built on it would inherit it. `rproxy` writes nothing to disk, and
|
||||||
volume.
|
needs no volume.
|
||||||
|
|
||||||
`waf` keeps its cache files in `SWW_WAF_CACHE_DIR`, `/var/cache/sww` by default,
|
`waf` keeps its cache files in `SWW_WAF_CACHE_DIR`, `/var/cache/sww` by default,
|
||||||
which the `run` script makes. The cache directory needs no volume and can be
|
which the `run` script makes. The cache directory needs no volume and can be
|
||||||
@@ -2996,9 +3026,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 traefik's address, which `rproxy` adds after the visitor's. So the app must
|
with the TLS-terminating proxy's address, which `rproxy` adds after the
|
||||||
trust `rproxy`'s address for forwarded headers, besides the private address
|
visitor's. So the app must trust `rproxy`'s address for forwarded headers,
|
||||||
ranges traefik 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
-2
@@ -68,7 +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 traefik.
|
- `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.
|
- `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.
|
||||||
@@ -237,7 +238,8 @@ meanings where they differ:
|
|||||||
- `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 `waf`'s answer, or the value of
|
is `waf`'s answer, or the value of
|
||||||
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION`. It is left out when:
|
`SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION`. It is left out when:
|
||||||
- `waf` was not asked, as for the health check;
|
- `waf` was not asked and `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` did
|
||||||
|
not apply, as for the health check;
|
||||||
- the visitor went away while `waf` was being asked;
|
- the visitor went away while `waf` was being asked;
|
||||||
- `waf`'s answer had no valid `X-Sww-Disposition`;
|
- `waf`'s answer had no valid `X-Sww-Disposition`;
|
||||||
- `rproxy` could not pass on `waf`'s answer, and answered `502`.
|
- `rproxy` could not pass on `waf`'s answer, and answered `502`.
|
||||||
|
|||||||
+11
-10
@@ -150,7 +150,7 @@ cut off; the client is banned.
|
|||||||
- `SWW_WAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off`
|
- `SWW_WAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off`
|
||||||
stops the start while a setting that needs a lookup is set:
|
stops the start while a setting that needs a lookup is set:
|
||||||
- `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES`;
|
- `SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES`;
|
||||||
- `SWW_WAF_ADD_LOOKUP_HEADERS`;
|
- `SWW_WAF_ADD_LOOKUP_HEADERS` set to `true`;
|
||||||
- `SWW_WAF_TAG_DENY` or `SWW_WAF_TAG_LIMIT_PERCENT` naming a `country=` tag,
|
- `SWW_WAF_TAG_DENY` or `SWW_WAF_TAG_LIMIT_PERCENT` naming a `country=` tag,
|
||||||
or an `asn=` tag while `SWW_WAF_ROUTEWATCH_URL` is unset;
|
or an `asn=` tag while `SWW_WAF_ROUTEWATCH_URL` is unset;
|
||||||
- the two byte percentage lists and `SWW_WAF_ASN_LIMIT_PERCENT_URL`;
|
- the two byte percentage lists and `SWW_WAF_ASN_LIMIT_PERCENT_URL`;
|
||||||
@@ -161,9 +161,11 @@ cut off; the client is banned.
|
|||||||
- `SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS` (default `800`): how long a
|
- `SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS` (default `800`): how long a
|
||||||
request waits for its client's first answer from GeoJS, and how long a request
|
request waits for its client's first answer from GeoJS, and how long a request
|
||||||
to GeoJS may take. A time in milliseconds; never `off`.
|
to GeoJS may take. A time in milliseconds; never `off`.
|
||||||
- `SWW_WAF_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the
|
- `SWW_WAF_ADD_LOOKUP_HEADERS` (default `true`): `true` has `rproxy` pass the
|
||||||
app the client's AS number in `X-Client-ASN` and its country in
|
app the client's AS number in `X-Client-ASN` and its country in
|
||||||
`X-Client-Country`.
|
`X-Client-Country`. The default is `false` while `SWW_WAF_LOOKUP_SOURCE` is
|
||||||
|
`off`. With GeoJS, a new client's first request waits for its answer, up to
|
||||||
|
`SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS`.
|
||||||
|
|
||||||
## Country list
|
## Country list
|
||||||
|
|
||||||
@@ -451,10 +453,9 @@ start, and an invalid value stops the start. Any can be given as a file, as a
|
|||||||
for `waf`'s answer, to a question and to a report. A time in milliseconds;
|
for `waf`'s answer, to a question and to a report. A time in milliseconds;
|
||||||
never `off`.
|
never `off`.
|
||||||
- `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` (default `allow`): what is done
|
- `SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION` (default `allow`): what is done
|
||||||
with a request when `waf` cannot be connected to, does not answer at all, or
|
with a request when `waf` gives no answer in time, and while the circuit
|
||||||
does not answer within `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`. `allow` passes it
|
breaker for asks to `waf` is open. `allow` passes it to the app, and `deny`
|
||||||
to the app, and `deny` answers `503`. It does not apply to an answer `waf`
|
answers `503`. It does not apply to an answer `waf` gives, such as an error.
|
||||||
gives, such as an error.
|
|
||||||
- `SWW_RPROXY_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A
|
- `SWW_RPROXY_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A
|
||||||
size or `off`.
|
size or `off`.
|
||||||
- `SWW_RPROXY_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A
|
- `SWW_RPROXY_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A
|
||||||
@@ -476,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 traefik, they
|
reach `rproxy` directly, or visitors on a private network reach the
|
||||||
can name another address in the header. Narrowing the setting to traefik's own
|
TLS-terminating proxy, they can name another address in the header. Narrowing
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ type WAF struct {
|
|||||||
// client's first answer from GeoJS while a setting needs it
|
// client's first answer from GeoJS while a setting needs it
|
||||||
// (SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS), which cannot be off.
|
// (SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS), which cannot be off.
|
||||||
// AddLookupHeaders is true when the app is passed the client's AS number
|
// AddLookupHeaders is true when the app is passed the client's AS number
|
||||||
// and country in headers (SWW_WAF_ADD_LOOKUP_HEADERS).
|
// and country in headers (SWW_WAF_ADD_LOOKUP_HEADERS), by default unless
|
||||||
|
// LookupSource is off.
|
||||||
LookupSource string
|
LookupSource string
|
||||||
LookupDBPath string
|
LookupDBPath string
|
||||||
ASNAndCountryLookupTimeout time.Duration
|
ASNAndCountryLookupTimeout time.Duration
|
||||||
@@ -549,7 +550,6 @@ func WAFFromEnvironment(v *viper.Viper) (*WAF, error) {
|
|||||||
"SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS",
|
"SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS",
|
||||||
"800",
|
"800",
|
||||||
),
|
),
|
||||||
AddLookupHeaders: env.boolean("SWW_WAF_ADD_LOOKUP_HEADERS", "false"),
|
|
||||||
ExclusivelyAllowedCountries: env.countries(
|
ExclusivelyAllowedCountries: env.countries(
|
||||||
"SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
|
"SWW_WAF_EXCLUSIVELY_ALLOWED_COUNTRIES", ""),
|
||||||
TagLists: env.tagLists("SWW_WAF_TAG_LISTS"),
|
TagLists: env.tagLists("SWW_WAF_TAG_LISTS"),
|
||||||
@@ -724,6 +724,9 @@ func WAFFromEnvironment(v *viper.Viper) (*WAF, error) {
|
|||||||
|
|
||||||
cfg.LogRemoteAppName = env.appName("SWW_WAF_LOG_REMOTE_APP_NAME",
|
cfg.LogRemoteAppName = env.appName("SWW_WAF_LOG_REMOTE_APP_NAME",
|
||||||
cfg.InstanceName, cfg.LogRemoteURL != nil)
|
cfg.InstanceName, cfg.LogRemoteURL != nil)
|
||||||
|
// The app gets the headers by default, unless no client is looked up.
|
||||||
|
cfg.AddLookupHeaders = env.boolean("SWW_WAF_ADD_LOOKUP_HEADERS",
|
||||||
|
strconv.FormatBool(cfg.LookupSource != off))
|
||||||
cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action(
|
cfg.BlocklistAction, cfg.BlocklistLimitPercent = env.action(
|
||||||
"SWW_WAF_BLOCKLIST_ACTION", "deny")
|
"SWW_WAF_BLOCKLIST_ACTION", "deny")
|
||||||
cfg.DNSBLAbuseIPDBAction, cfg.DNSBLAbuseIPDBLimitPercent = env.action(
|
cfg.DNSBLAbuseIPDBAction, cfg.DNSBLAbuseIPDBLimitPercent = env.action(
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ func TestDefaults(t *testing.T) {
|
|||||||
})
|
})
|
||||||
wantLookupSettings(t, cfg, config.WAF{
|
wantLookupSettings(t, cfg, config.WAF{
|
||||||
LookupSource: defaultLookupSource, ASNAndCountryLookupTimeout: 800 * time.Millisecond,
|
LookupSource: defaultLookupSource, ASNAndCountryLookupTimeout: 800 * time.Millisecond,
|
||||||
|
AddLookupHeaders: true,
|
||||||
})
|
})
|
||||||
wantByteLimitSettings(t, cfg, config.WAF{
|
wantByteLimitSettings(t, cfg, config.WAF{
|
||||||
BytesLimitPerMinute: 0, BytesLimitPerHour: 50 << 30,
|
BytesLimitPerMinute: 0, BytesLimitPerHour: 50 << 30,
|
||||||
@@ -1395,13 +1396,13 @@ func TestLookupSettingsAsSet(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg := wafFromEnvironment(t, environment{
|
cfg := wafFromEnvironment(t, environment{
|
||||||
asnAndCountryLookupTimeout: "500", addLookupHeaders: enabled,
|
asnAndCountryLookupTimeout: "500", addLookupHeaders: disabled,
|
||||||
})
|
})
|
||||||
wantLookupSettings(t, cfg, config.WAF{
|
wantLookupSettings(t, cfg, config.WAF{
|
||||||
LookupSource: defaultLookupSource, ASNAndCountryLookupTimeout: 500 * time.Millisecond,
|
LookupSource: defaultLookupSource, ASNAndCountryLookupTimeout: 500 * time.Millisecond,
|
||||||
AddLookupHeaders: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// With no client looked up, the headers are off unless set.
|
||||||
cfg = wafFromEnvironment(t, environment{lookupSource: off})
|
cfg = wafFromEnvironment(t, environment{lookupSource: off})
|
||||||
wantLookupSettings(t, cfg, config.WAF{
|
wantLookupSettings(t, cfg, config.WAF{
|
||||||
LookupSource: off, ASNAndCountryLookupTimeout: 800 * time.Millisecond,
|
LookupSource: off, ASNAndCountryLookupTimeout: 800 * time.Millisecond,
|
||||||
@@ -1422,6 +1423,10 @@ func TestLookupDBPathGoesWithTheFileSourceAlone(t *testing.T) {
|
|||||||
cfg.LookupSource, cfg.LookupDBPath, path)
|
cfg.LookupSource, cfg.LookupDBPath, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !cfg.AddLookupHeaders {
|
||||||
|
t.Error("headers off by default with lookups from file, want on")
|
||||||
|
}
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
env environment
|
env environment
|
||||||
want string
|
want string
|
||||||
@@ -2927,7 +2932,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
lookupSource: defaultLookupSource,
|
lookupSource: defaultLookupSource,
|
||||||
lookupDBPath: "",
|
lookupDBPath: "",
|
||||||
asnAndCountryLookupTimeout: "800",
|
asnAndCountryLookupTimeout: "800",
|
||||||
addLookupHeaders: disabled,
|
addLookupHeaders: enabled,
|
||||||
allowedCountries: "",
|
allowedCountries: "",
|
||||||
tagLists: "",
|
tagLists: "",
|
||||||
tagDeny: "",
|
tagDeny: "",
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ const (
|
|||||||
// the app.
|
// the app.
|
||||||
ActionForward = "forward"
|
ActionForward = "forward"
|
||||||
|
|
||||||
// ActionRequestLineTooLong is a request whose request line is over the
|
// ActionRequestLineTooLong is a request whose request line is too long for
|
||||||
// header limits' longest line.
|
// the header limits.
|
||||||
ActionRequestLineTooLong = "request_line_too_long"
|
ActionRequestLineTooLong = "request_line_too_long"
|
||||||
|
|
||||||
// ActionRequestHeaderTooLarge is a request whose headers are over the
|
// ActionRequestHeaderTooLarge is a request whose headers are over the
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package rproxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What opens the circuit breaker, and how often a request asks waf while it is
|
||||||
|
// open.
|
||||||
|
const (
|
||||||
|
breakerFailures = 5
|
||||||
|
breakerWindow = 5 * time.Second
|
||||||
|
breakerAskInterval = time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// errBreakerOpen is why a request did not ask waf.
|
||||||
|
var errBreakerOpen = errors.New(
|
||||||
|
"waf is not asked while the circuit breaker is open",
|
||||||
|
)
|
||||||
|
|
||||||
|
// breaker is the circuit breaker for asks to waf. It opens when
|
||||||
|
// breakerFailures asks fail within breakerWindow. An ask fails when it gets no
|
||||||
|
// answer in time, or an answer rproxy cannot pass on. While it is open, at
|
||||||
|
// most one request each breakerAskInterval asks waf. Any usable answer closes
|
||||||
|
// it, whatever it decides, and forgets the failures before it.
|
||||||
|
type breaker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
// failures holds the times of the latest failed asks, oldest first, at most
|
||||||
|
// breakerFailures of them.
|
||||||
|
failures []time.Time
|
||||||
|
open bool
|
||||||
|
// lastAsk is when a request last asked waf while the breaker was open.
|
||||||
|
lastAsk time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// mayAsk reports whether a request may ask waf at now. While the breaker is
|
||||||
|
// open, it says yes once each breakerAskInterval.
|
||||||
|
func (b *breaker) mayAsk(now time.Time) bool {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
if !b.open {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if now.Sub(b.lastAsk) < breakerAskInterval {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
b.lastAsk = now
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteAnswer notes that an ask got an answer rproxy can use. It closes the
|
||||||
|
// breaker and forgets the failures before it.
|
||||||
|
func (b *breaker) noteAnswer() {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
b.open = false
|
||||||
|
b.failures = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteFailure notes that an ask that ended at now got no answer in time, and
|
||||||
|
// opens the breaker if breakerFailures asks have failed within breakerWindow.
|
||||||
|
func (b *breaker) noteFailure(now time.Time) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
b.failures = append(b.failures, now)
|
||||||
|
if len(b.failures) > breakerFailures {
|
||||||
|
b.failures = b.failures[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if !b.open && len(b.failures) == breakerFailures &&
|
||||||
|
now.Sub(b.failures[0]) <= breakerWindow {
|
||||||
|
b.open = true
|
||||||
|
b.lastAsk = now
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package rproxy_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The breaker tests give the breaker each time themselves, counted from
|
||||||
|
// time.Now(), and never wait.
|
||||||
|
|
||||||
|
// fail notes count failed asks on b, one each step from at.
|
||||||
|
func fail(b *rproxy.Breaker, at time.Time, count int, step time.Duration) {
|
||||||
|
for i := range count {
|
||||||
|
b.NoteFailure(at.Add(time.Duration(i) * step))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openBreaker returns a breaker opened at now by five failed asks, one a
|
||||||
|
// second.
|
||||||
|
func openBreaker(now time.Time) *rproxy.Breaker {
|
||||||
|
b := &rproxy.Breaker{}
|
||||||
|
fail(b, now.Add(-4*time.Second), 5, time.Second)
|
||||||
|
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFourFailedAsksWithinFiveSecondsLeaveTheBreakerClosed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
b := &rproxy.Breaker{}
|
||||||
|
fail(b, now, 4, time.Second)
|
||||||
|
|
||||||
|
if !b.MayAsk(now.Add(3 * time.Second)) {
|
||||||
|
t.Error("a request may not ask, want it to")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFiveFailedAsksWithinFiveSecondsOpenTheBreaker(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
b := openBreaker(now)
|
||||||
|
|
||||||
|
if b.MayAsk(now) {
|
||||||
|
t.Error("a request may ask, want it not to")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFiveFailedAsksOverMoreThanFiveSecondsLeaveTheBreakerClosed(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
b := &rproxy.Breaker{}
|
||||||
|
fail(b, now, 5, 1300*time.Millisecond)
|
||||||
|
|
||||||
|
if !b.MayAsk(now.Add(6 * time.Second)) {
|
||||||
|
t.Error("a request may not ask, want it to")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAnswerBetweenFailedAsksKeepsTheBreakerClosed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
b := &rproxy.Breaker{}
|
||||||
|
fail(b, now, 4, 100*time.Millisecond)
|
||||||
|
b.NoteAnswer()
|
||||||
|
fail(b, now.Add(500*time.Millisecond), 4, 100*time.Millisecond)
|
||||||
|
|
||||||
|
if !b.MayAsk(now.Add(900 * time.Millisecond)) {
|
||||||
|
t.Error("a request may not ask, want it to")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnOpenBreakerLetsOneRequestAskEachSecond(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
b := openBreaker(now)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
after time.Duration
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{999 * time.Millisecond, false},
|
||||||
|
{time.Second, true},
|
||||||
|
{time.Second, false},
|
||||||
|
{1500 * time.Millisecond, false},
|
||||||
|
{2 * time.Second, true},
|
||||||
|
} {
|
||||||
|
got := b.MayAsk(now.Add(tc.after))
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("may ask %v after it opened: %v, want %v",
|
||||||
|
tc.after, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFailedAskKeepsTheBreakerOpen(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
opened := time.Now()
|
||||||
|
b := openBreaker(opened)
|
||||||
|
now := opened.Add(time.Second)
|
||||||
|
b.MayAsk(now)
|
||||||
|
b.NoteFailure(now)
|
||||||
|
|
||||||
|
if b.MayAsk(now.Add(999 * time.Millisecond)) {
|
||||||
|
t.Error("a request may ask, want it not to")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !b.MayAsk(now.Add(time.Second)) {
|
||||||
|
t.Error("a request may not ask a second later, want it to")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOneAnswerClosesTheBreaker(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
opened := time.Now()
|
||||||
|
b := openBreaker(opened)
|
||||||
|
now := opened.Add(time.Second)
|
||||||
|
b.MayAsk(now)
|
||||||
|
b.NoteAnswer()
|
||||||
|
|
||||||
|
if !b.MayAsk(now) {
|
||||||
|
t.Error("a request may not ask, want it to")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The failures before the answer no longer count, so one more does not open
|
||||||
|
// the breaker again.
|
||||||
|
b.NoteFailure(now)
|
||||||
|
|
||||||
|
if !b.MayAsk(now) {
|
||||||
|
t.Error("a request may not ask after one failure, want it to")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package rproxy
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Breaker is the circuit breaker, for tests that give it the time.
|
||||||
|
type Breaker struct {
|
||||||
|
b breaker
|
||||||
|
}
|
||||||
|
|
||||||
|
// MayAsk reports whether a request may ask waf at now.
|
||||||
|
func (b *Breaker) MayAsk(now time.Time) bool {
|
||||||
|
return b.b.mayAsk(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoteAnswer notes that an ask got an answer rproxy can use.
|
||||||
|
func (b *Breaker) NoteAnswer() {
|
||||||
|
b.b.noteAnswer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoteFailure notes that an ask that ended at now got no answer in time.
|
||||||
|
func (b *Breaker) NoteFailure(now time.Time) {
|
||||||
|
b.b.noteFailure(now)
|
||||||
|
}
|
||||||
@@ -4,10 +4,11 @@
|
|||||||
// waf goes to the visitor as waf gave it.
|
// waf goes to the visitor as waf gave it.
|
||||||
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION decides only when waf cannot
|
// SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION decides only when waf cannot
|
||||||
// be connected to, does not answer at all, or does not answer within
|
// be connected to, does not answer at all, or does not answer within
|
||||||
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. rproxy itself refuses a request with a
|
// SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, or while the circuit breaker for asks to
|
||||||
// method it does not pass on, or one it cannot put to waf as the visitor
|
// waf is open. rproxy itself refuses a request with a method it does not pass
|
||||||
// sent it. It keeps nothing between requests and writes nothing to disk. "rproxy" in
|
// on, or one it cannot put to waf as the visitor sent it. It keeps nothing
|
||||||
// SPEC.md says what it does.
|
// between requests but the circuit breaker, and writes nothing to disk.
|
||||||
|
// "rproxy" in SPEC.md says what it does.
|
||||||
package rproxy
|
package rproxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -132,6 +133,8 @@ type handler struct {
|
|||||||
wafTransport http.RoundTripper
|
wafTransport http.RoundTripper
|
||||||
// reportURL is where the reports go.
|
// reportURL is where the reports go.
|
||||||
reportURL string
|
reportURL string
|
||||||
|
// breaker is the circuit breaker for asks to waf.
|
||||||
|
breaker breaker
|
||||||
// reports waits for the reports being sent. reportsMu guards starting one
|
// reports waits for the reports being sent. reportsMu guards starting one
|
||||||
// against reportsStopped, which is set once nothing is to wait for another.
|
// against reportsStopped, which is set once nothing is to wait for another.
|
||||||
reports sync.WaitGroup
|
reports sync.WaitGroup
|
||||||
@@ -196,7 +199,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
answer, err := rq.ask(r.Context())
|
answer, err := rq.askUnlessBreakerOpen(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rq.handleNoUsableAnswer(r.Context(), err)
|
rq.handleNoUsableAnswer(r.Context(), err)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -1395,6 +1396,139 @@ func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
|
|||||||
standIn.wantNoReport(t)
|
standIn.wantNoReport(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFiveFailedAsksKeepTheNextRequestFromAskingWAF(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for name, failure := range map[string]http.HandlerFunc{
|
||||||
|
"no answer at all": func(http.ResponseWriter, *http.Request) {
|
||||||
|
panic(http.ErrAbortHandler)
|
||||||
|
},
|
||||||
|
"a slow answer": func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
<-r.Context().Done()
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var answering atomic.Bool
|
||||||
|
|
||||||
|
standIn := startWAF(
|
||||||
|
t,
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if answering.Load() {
|
||||||
|
allow(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
failure(w, r)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
app := startApp(t, greet)
|
||||||
|
addr, out := startRProxy(
|
||||||
|
t,
|
||||||
|
standIn.URL,
|
||||||
|
app.URL,
|
||||||
|
map[string]string{
|
||||||
|
wafBackendTimeout: "200",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
for range 5 {
|
||||||
|
wantGreeting(t, get(t, addr, "/"))
|
||||||
|
standIn.question(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
answering.Store(true)
|
||||||
|
wantGreeting(t, get(t, addr, "/"))
|
||||||
|
out.waitFor(t, "the circuit breaker is open")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-standIn.questions:
|
||||||
|
t.Error("waf was asked, want it not to be")
|
||||||
|
case <-time.After(10 * pollInterval):
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVisitorsThatGoAwayWhileWAFIsAskedLeaveTheBreakerClosed(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var answering atomic.Bool
|
||||||
|
|
||||||
|
standIn := startWAF(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if answering.Load() {
|
||||||
|
allow(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
<-r.Context().Done()
|
||||||
|
})
|
||||||
|
app := startApp(t, greet)
|
||||||
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
|
wafBackendTimeout: "60000",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Five of these would open the breaker if they counted as failures.
|
||||||
|
for range 5 {
|
||||||
|
conn := dial(t, addr)
|
||||||
|
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
||||||
|
id := standIn.question(t).header.Get(requestIDHeader)
|
||||||
|
|
||||||
|
_ = conn.Close()
|
||||||
|
|
||||||
|
// rproxy writes the request's log line once it is done with the ask.
|
||||||
|
out.waitFor(t, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
answering.Store(true)
|
||||||
|
wantGreeting(t, get(t, addr, "/"))
|
||||||
|
standIn.question(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnswersThatCannotBePassedOnOpenTheBreaker(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var answering atomic.Bool
|
||||||
|
|
||||||
|
tooLarge := overOneMiB(dispositionDeny)
|
||||||
|
standIn := startWAF(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if answering.Load() {
|
||||||
|
allow(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tooLarge(w, r)
|
||||||
|
})
|
||||||
|
app := startApp(t, greet)
|
||||||
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
|
wafBackendTimeout: "60000",
|
||||||
|
})
|
||||||
|
|
||||||
|
for range 5 {
|
||||||
|
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
|
||||||
|
t.Errorf("answered %d, want 502", got.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
standIn.question(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
answering.Store(true)
|
||||||
|
wantGreeting(t, get(t, addr, "/"))
|
||||||
|
out.waitFor(t, "the circuit breaker is open")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-standIn.questions:
|
||||||
|
t.Error("waf was asked, want it not to be")
|
||||||
|
case <-time.After(10 * pollInterval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) {
|
func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+28
-1
@@ -10,6 +10,7 @@ import (
|
|||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
)
|
)
|
||||||
@@ -67,7 +68,8 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
|||||||
rq.answer(http.StatusBadGateway)
|
rq.answer(http.StatusBadGateway)
|
||||||
default:
|
default:
|
||||||
// waf cannot be connected to, does not answer at all, or does not answer
|
// waf cannot be connected to, does not answer at all, or does not answer
|
||||||
// within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. A request that keeps waf
|
// within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS; or the circuit breaker
|
||||||
|
// kept the request from asking. A request that keeps waf
|
||||||
// busy past SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, like any request while
|
// busy past SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, like any request while
|
||||||
// waf is overloaded, gets SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION
|
// waf is overloaded, gets SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION
|
||||||
// here. deny closes this, at the cost of answering 503 whenever waf is
|
// here. deny closes this, at the cost of answering 503 whenever waf is
|
||||||
@@ -86,6 +88,31 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// askUnlessBreakerOpen asks waf, unless the circuit breaker is open and it is
|
||||||
|
// not yet time for a request to ask. It then tells the breaker whether waf
|
||||||
|
// answered in time. An answer rproxy cannot pass on counts as a failure. A
|
||||||
|
// visitor that goes away tells the breaker nothing. ctx is the request's own
|
||||||
|
// context.
|
||||||
|
func (rq *request) askUnlessBreakerOpen(
|
||||||
|
ctx context.Context,
|
||||||
|
) (*wafAnswer, error) {
|
||||||
|
b := &rq.h.breaker
|
||||||
|
if !b.mayAsk(time.Now()) {
|
||||||
|
return nil, errBreakerOpen
|
||||||
|
}
|
||||||
|
|
||||||
|
answer, err := rq.ask(ctx)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
b.noteAnswer()
|
||||||
|
case ctx.Err() == nil:
|
||||||
|
b.noteFailure(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
return answer, err
|
||||||
|
}
|
||||||
|
|
||||||
// ask sends waf the question, and returns its answer, which must have come
|
// ask sends waf the question, and returns its answer, which must have come
|
||||||
// whole within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. It ends when ctx does, as
|
// whole within SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS. It ends when ctx does, as
|
||||||
// when the visitor goes away.
|
// when the visitor goes away.
|
||||||
|
|||||||
@@ -260,7 +260,17 @@ func TestTheSiteAnswersWhileWAFIsStoppedAndStartedAgain(t *testing.T) {
|
|||||||
stop()
|
stop()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
wantStatus(t, site+"/", placed, http.StatusForbidden)
|
// The visitor's asks have opened rproxy's circuit breaker, which lets one
|
||||||
|
// request a second ask until one is answered.
|
||||||
|
deadline := time.Now().Add(waitLimit)
|
||||||
|
for statusFrom(t, site+"/", placed) != http.StatusForbidden {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("request from %s: never answered %d", placed,
|
||||||
|
http.StatusForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(pollInterval)
|
||||||
|
}
|
||||||
|
|
||||||
close(visitorStop)
|
close(visitorStop)
|
||||||
<-visitorDone
|
<-visitorDone
|
||||||
|
|||||||
@@ -349,6 +349,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
|
|||||||
map[string]string{
|
map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
asnAndCountryLookupTimeout: "3600000",
|
asnAndCountryLookupTimeout: "3600000",
|
||||||
|
addLookupHeaders: falseValue,
|
||||||
anomalyASN + requestsPerMinute: aLot,
|
anomalyASN + requestsPerMinute: aLot,
|
||||||
})
|
})
|
||||||
server.Store(started)
|
server.Store(started)
|
||||||
|
|||||||
@@ -204,6 +204,10 @@ func TestAHeaderLineOverTheLimitIsRefusedWith431(t *testing.T) {
|
|||||||
{"a Host line over the limit", func(req *http.Request) {
|
{"a Host line over the limit", func(req *http.Request) {
|
||||||
req.Host = strings.Repeat("a", lineLimit+1-len("Host: "))
|
req.Host = strings.Repeat("a", lineLimit+1-len("Host: "))
|
||||||
}, true},
|
}, 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.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -387,14 +387,16 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
|||||||
|
|
||||||
// In a synctest bubble, as
|
// In a synctest bubble, as
|
||||||
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says,
|
// TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays says,
|
||||||
// with a GeoJS that never answers.
|
// with a GeoJS that never answers. The headers, which need the
|
||||||
|
// answer too, are off.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
|
server, out, _ := newWAF(t, silentGeoJSURL, time.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
asnAndCountryLookupTimeout: strconv.FormatInt(
|
asnAndCountryLookupTimeout: strconv.FormatInt(
|
||||||
timeout.Milliseconds(), 10),
|
timeout.Milliseconds(), 10),
|
||||||
tagLists: "vpn=" + vpnList,
|
addLookupHeaders: falseValue,
|
||||||
tc.setting: tc.value,
|
tagLists: "vpn=" + vpnList,
|
||||||
|
tc.setting: tc.value,
|
||||||
})
|
})
|
||||||
began := time.Now()
|
began := time.Now()
|
||||||
|
|
||||||
|
|||||||
@@ -108,8 +108,11 @@ func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
|
// The headers are off, so that only the country list makes the request
|
||||||
|
// wait for its answer.
|
||||||
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
||||||
asnAndCountryLookupTimeout: "3600000", allowedCountries: "de",
|
asnAndCountryLookupTimeout: "3600000", addLookupHeaders: falseValue,
|
||||||
|
allowedCountries: "de",
|
||||||
})
|
})
|
||||||
|
|
||||||
line := s.get(
|
line := s.get(
|
||||||
@@ -239,7 +242,10 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
env map[string]string
|
env map[string]string
|
||||||
}{
|
}{
|
||||||
{"no setting needs the lookup", nil},
|
{
|
||||||
|
"no setting needs the lookup",
|
||||||
|
map[string]string{addLookupHeaders: falseValue},
|
||||||
|
},
|
||||||
{"a tag setting needs it", map[string]string{tagDeny: denyKP}},
|
{"a tag setting needs it", map[string]string{tagDeny: denyKP}},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -12,7 +12,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 SWW_WAF_ADD_LOOKUP_HEADERS is set. Go writes
|
// and country, for the app, while SWW_WAF_ADD_LOOKUP_HEADERS is true. 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 (
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(
|
|||||||
geojsURL, asked, release := startHeldGeoJS(t)
|
geojsURL, asked, release := startHeldGeoJS(t)
|
||||||
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
s, _, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
asnAndCountryLookupTimeout: "3600000",
|
asnAndCountryLookupTimeout: "3600000",
|
||||||
|
addLookupHeaders: falseValue,
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -418,7 +419,7 @@ func TestClientsOwnLookupHeadersAreRemovedWhileTheSettingIsOff(
|
|||||||
geojsURL,
|
geojsURL,
|
||||||
time.Now,
|
time.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost, addLookupHeaders: falseValue,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
s := &sender{t: t, addr: addr, out: out}
|
s := &sender{t: t, addr: addr, out: out}
|
||||||
@@ -457,6 +458,8 @@ func TestRequestWaitsAsLongAsTheASNAndCountryLookupTimeoutSays(
|
|||||||
map[string]string{
|
map[string]string{
|
||||||
asnAndCountryLookupTimeout: strconv.FormatInt(
|
asnAndCountryLookupTimeout: strconv.FormatInt(
|
||||||
timeout.Milliseconds(), 10),
|
timeout.Milliseconds(), 10),
|
||||||
|
// Off, so that only the country list makes the request wait.
|
||||||
|
addLookupHeaders: falseValue,
|
||||||
allowedCountries: "DE",
|
allowedCountries: "DE",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ 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"
|
||||||
|
|
||||||
|
// falseValue is the value that switches a setting that takes true or false
|
||||||
|
// off.
|
||||||
|
const falseValue = "false"
|
||||||
|
|
||||||
// output collects what sww writes on stdout.
|
// output collects what sww writes on stdout.
|
||||||
type output struct {
|
type output struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|||||||
+1
-1
@@ -320,7 +320,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
|
||||||
// SWW_WAF_ADD_LOOKUP_HEADERS is set, the client's AS number and country.
|
// SWW_WAF_ADD_LOOKUP_HEADERS is true, the client's AS number and country.
|
||||||
func (rq *request) allow() {
|
func (rq *request) allow() {
|
||||||
rq.allowed = true
|
rq.allowed = true
|
||||||
|
|
||||||
|
|||||||
@@ -869,7 +869,7 @@ func TestAllowCarriesTheLookupHeadersWhileTheSettingIsSet(t *testing.T) {
|
|||||||
wantASN, wantCountry string
|
wantASN, wantCountry string
|
||||||
}{
|
}{
|
||||||
{on, "AS64496", "DE"},
|
{on, "AS64496", "DE"},
|
||||||
{"false", "", ""},
|
{falseValue, "", ""},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.setting, func(t *testing.T) {
|
t.Run(tc.setting, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
Reference in new issue
Block a user