Compare commits

..
Author SHA1 Message Date
sneak 7a627f44e1 waf: decide every visitor request within rproxy's timeout (closes #394)
check / check (push) Successful in 6m36s
The Core Rule Set is the one check whose time a visitor can stretch.
At paranoia level 4 it took over 2 s on a request with an 8 KiB
request line, three 8 KiB Cookie lines and a 64 KiB form body.
SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE now takes at most 8K, which
brings that request under 1 s.

A test per kind of input times the Core Rule Set on the worst request
within the limits, at the slowest settings. SPEC.md lists the limits
that bound it.

Model: opus-5-5
2026-11-15 17:52:21 +00:00
clawbot 23788920f7 rproxy: circuit breaker for asks to waf (closes #392)
check / check (push) Failing after 1m55s
Five failed asks to waf within five seconds open the breaker. An ask
fails when it gets no answer in time, slow answers included, or an
answer rproxy cannot pass on. Every usable answer clears the failures
and closes the breaker. While open, a request takes
SWW_RPROXY_WAF_BACKEND_NO_ANSWER_DISPOSITION without asking waf, except
one ask a second. SPEC.md describes it.

The sww test that stops and restarts waf now waits for the breaker to
close before it expects the ban again.

Model: opus-5-5
2026-11-15 16:56:45 +01:00
clawbot 8efd6339a6 README: what rproxy gives an app without waf (closes #386)
check / check (push) Failing after 2m29s
Adds a paragraph to "What it does" listing what rproxy enforces with
default settings whether or not waf answers: malformed requests, methods,
header size, request and response times, body sizes, idle connections.
After the list, it says which header limits are waf's.

Removes the "Slow and oversized requests" bullet, which the new list
states with figures.

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

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

Model: opus-5-5
2026-11-14 18:41:10 +01:00
clawbot 53d9bf6af6 waf: refuse request lines and header lines over 8 KiB first (closes #339)
check / check (push) Failing after 4m24s
The header limits now also refuse a request line over 8 KiB with 414
(action request_line_too_long) and a header line over 8 KiB, Host
included, with 431. Like the other header limits they are fixed, run
first and make no ban. One long query string or cookie no longer keeps
the Core Rule Set busy for a third of a second.

Model: opus-5-5
2026-11-13 21:56:09 +01:00
clawbot 3550956cbb rproxy passes the app the client's AS number and country by default (closes #379)
check / check (push) Successful in 4m19s
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 21:27:07 +01:00
22 changed files with 1254 additions and 182 deletions

No files matched your search

+100 -29
View File
@@ -2,7 +2,20 @@
`smallwebwaf` is an MIT-licensed web application firewall, written in Go by `smallwebwaf` is an MIT-licensed web application firewall, written in Go by
[@sneak](https://sneak.berlin), for self-hosted web apps. It refuses the [@sneak](https://sneak.berlin), for self-hosted web apps. It refuses the
requests of scrapers, scanners and other abusers before they reach an app. requests of misbehaving scrapers, scanners and other abusers before they reach
an app.
With the default settings, a client is banned when it does any of these:
- makes more than 120 requests in a minute, 1500 in an hour or 6000 in a day;
- moves more than `50G` of request and response bodies in an hour, or `100G` in
a day;
- asks for a secret or backup file, such as `/.env` or `/.git/config`;
- sends a user agent that names a security scanner, such as `sqlmap` or `nikto`;
- has more than 30 requests in a minute refused as probes or attacks.
A scraper that does none of these is left alone. A scraper that waits 15 seconds
between requests stays under the rate limits.
## What it does ## What it does
@@ -10,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. |
@@ -278,7 +348,7 @@ Every setting the code reads, with its default:
| `SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. | | `SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
| `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. | | `SWW_WAF_CORE_RULE_SET_DISABLED_RULES` | `920340,920420,920440,920640,930130,930140` | Core Rule Set rule ids switched off; a list you give replaces the default, so repeat these six to keep them off. |
| `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. | | `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
| `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. | | `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` | `off` | How much of a request body the Core Rule Set reads, a size up to `8K`; `off` reads none. |
| `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. | | `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
| `SWW_WAF_APPS` | unset | The apps each hostname serves, such as `git.example.com:gitea;blog.example.com:wordpress`; only those apps' fixes apply there. "Attack detection" lists the apps. Listing `forgejo` brings gitea's fixes too. Unset, every app's fixes apply to every hostname. | | `SWW_WAF_APPS` | unset | The apps each hostname serves, such as `git.example.com:gitea;blog.example.com:wordpress`; only those apps' fixes apply there. "Attack detection" lists the apps. Listing `forgejo` brings gitea's fixes too. Unset, every app's fixes apply to every hostname. |
| `SWW_WAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. | | `SWW_WAF_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
@@ -540,9 +610,10 @@ 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 `431`, and bans instead. A request over the header limits of step 3 is answered `414` for a
nobody either. `rproxy` itself answers `405` to a method it does not pass on, request line over 8 KiB and `431` otherwise. It bans nobody either. `rproxy`
and the status for a size or time limit, such as `413` for a body too large. itself answers `405` to a method it does not pass on, and the status for a size
or time limit, such as `413` for a body too large.
### Clients and bans ### Clients and bans
+119 -73
View File
@@ -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.
@@ -151,11 +152,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 count header fields and cookies. after `SWW_WAF_ALLOW_NETS`, since they only measure lines and count header
Next, the tag lists (`tag_lists`; see "Request tags"). Each list that holds fields and cookies. Next, the tag lists (`tag_lists`; see "Request tags").
the client adds its tag. Next, `routewatch_tags` adds the client's `asn=` and Each list that holds the client adds its tag. Next, `routewatch_tags` adds
`prefix=` tags while `SWW_WAF_ROUTEWATCH_URL` is set. A client in the client's `asn=` and `prefix=` tags while `SWW_WAF_ROUTEWATCH_URL` is set.
`SWW_WAF_ALLOW_NETS` skips the header limits and gets no tag. A client in `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.
@@ -252,9 +253,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 `431` before the bans are A request over the header limits is refused with `414` or `431` before the bans
checked (see "Answering in time"). It makes no ban, makes no ban permanent and are checked (see "Answering in time"). It makes no ban, makes no ban permanent
is not counted in a ban's notes. and 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:
@@ -703,13 +704,13 @@ files, the limits and the bans still apply to the client that sends it.
rules is loaded yet. rules is loaded yet.
- `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` switches body inspection on, up - `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` switches body inspection on, up
to that size. It takes at most `64K`, since `waf` gets no more of a body than to that size. It takes at most `8K`, so that `waf` answers in time (see
that (see "The answer"). Form data and multipart bodies are read up to the "Answering in time"). Form data and multipart bodies are read up to the size.
size. JSON and XML bodies are read only when no larger than the size, since JSON and XML bodies are read only when no larger than the size, since those
those cannot be read in part. Any other body is not read: the Core Rule Set cannot be read in part. Any other body is not read: the Core Rule Set would
would read it as form data, where binary content such as a git push trips read it as form data, where binary content such as a git push trips rules
rules written for text. A body the Core Rule Set cannot parse adds 5 to the written for text. A body the Core Rule Set cannot parse adds 5 to the score,
score, and so does a multipart body cut by the limit in the middle of a part's and so does a multipart body cut by the limit in the middle of a part's
header. Body inspection suits apps whose forms carry no code. header. Body inspection suits apps whose forms carry no code.
- `SWW_WAF_TRAP_PATHS` names paths the app never serves and only scanners ask - `SWW_WAF_TRAP_PATHS` names paths the app never serves and only scanners ask
for, such as `/wp-login.php` in front of an app that is not WordPress. This is for, such as `/wp-login.php` in front of an app that is not WordPress. This is
@@ -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
@@ -2381,7 +2382,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;
- `431`, for a request over the header limits; - `414` or `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
@@ -2396,9 +2397,7 @@ In `observe` mode a request that `enforce` mode would refuse is answered
answered `404`. answered `404`.
The Core Rule Set inspects the body a question carries only while The Core Rule Set inspects the body a question carries only while
`SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set. It inspects at most 64 KiB `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` is set, and at most 8 KiB of it.
less one byte. So it does not inspect a JSON or XML body of 64 KiB or more,
which may have been cut short.
### Answering in time ### Answering in time
@@ -2442,33 +2441,51 @@ 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. `waf` refuses a request over The header limits are fixed, with no setting. A line's length is in bytes and
either one: does not count its line ending. `waf` refuses a request whose request line is
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 never reach these limits. The answer is `deny` with `431`, and the Real clients rarely reach these limits. nginx refuses lines over 8 KiB by
log line's action is `request_header_too_large`. The refusal makes no ban and is default, and Apache's default limit is about the same. The answer is `deny`. The
no offence. log line's action is `request_line_too_long` for `414` and
`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 `431` for such a request. client under a ban or in `SWW_WAF_DENY_NETS` also gets `414` or `431` for such a
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
`observe` mode such a request is answered `allow` with `would_action`, and no `observe` mode such a request is answered `allow` with `would_action`, and no
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 is the one check a visitor can keep busy for long. It runs
cookies would keep it busy for over a second. Within the limits, one long cookie each of its rules on each part of a request, so thousands of small cookies would
or query string can still keep it busy for a fraction of a second. So enough keep it busy for over a second. These fixed limits bound what it reads:
such requests at once can overload `waf` past
`SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`. - the header limits above;
- `rproxy`'s limit of 32 KiB on the request line and headers together (see
"`rproxy`'s limits");
- 8 KiB of a body, the most `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` takes.
A request can reach all of these at once. At paranoia level 4, with no rule
switched off, the Core Rule Set takes under a second on one core of a current
server to inspect it. So `waf` answers it within the default 2 seconds, even
after the default 800 milliseconds of waiting for GeoJS. Enough such requests at
once can still overload `waf` past `SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS`.
### The report ### The report
@@ -2572,11 +2589,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
@@ -2594,7 +2611,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`.
@@ -2668,23 +2686,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.
@@ -2699,6 +2718,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
@@ -2761,9 +2802,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:
@@ -2814,14 +2855,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
@@ -2893,10 +2936,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;
@@ -2929,23 +2972,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.
@@ -2967,8 +3013,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
@@ -2986,9 +3032,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.
+10 -4
View File
@@ -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.
@@ -102,7 +103,8 @@ 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_header_too_large`: over a header limit; - `request_line_too_long`: a request line over the header limits;
- `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
@@ -124,7 +126,10 @@ 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`: `fields`, `fields_limit`, `cookies` and `cookies_limit`; - `header_limits`: for a request line, `request_line_bytes` and
`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;
@@ -233,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`.
+12 -13
View File
@@ -251,10 +251,9 @@ tag that no request can get stops the start. A request can get only these:
- `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` (default empty): path prefixes the Core - `SWW_WAF_CORE_RULE_SET_EXEMPT_PATHS` (default empty): path prefixes the Core
Rule Set does not inspect. Rule Set does not inspect.
- `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` (default `off`): `off`, or how - `SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE` (default `off`): `off`, or how
much of a request body the Core Rule Set reads, a size of at most `64K`. A much of a request body the Core Rule Set reads, a size of at most `8K`. A
question carries no more of a body than 64 KiB, and the Core Rule Set reads at larger size would let one request keep it busy past `rproxy`'s timeout (see
most 64 KiB less one byte of it, whatever the value (see "The answer" in "Answering in time" in [`SPEC.md`](../SPEC.md)).
[`SPEC.md`](../SPEC.md)).
- `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule - `SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule
Set exclusions, written as Coraza directives. It is loaded before the Core Set exclusions, written as Coraza directives. It is loaded before the Core
Rule Set's rules, so it holds only `SecAction` and `SecRule` lines that Rule Set's rules, so it holds only `SecAction` and `SecRule` lines that
@@ -423,8 +422,9 @@ 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 more than 50 cookies or more than 100 header - `waf` refuses a request with a request line or header line over 8 KiB, more
fields (see "Answering in time" under "`waf`" in [`SPEC.md`](../SPEC.md)); than 50 cookies or more than 100 header fields (see "Answering in time" under
"`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`
@@ -452,10 +452,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
@@ -477,9 +476,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
+6 -6
View File
@@ -365,8 +365,8 @@ var (
"it names the file to look clients up in", "it names the file to look clients up in",
) )
errDBPathUnused = errors.New("only file reads it") errDBPathUnused = errors.New("only file reads it")
errOver64K = errors.New( errOver8K = errors.New(
"is more than 64K, the most waf gets of a body") "is more than 8K, the most the Core Rule Set can read in time")
errNotDurationAboveZero = errors.New( errNotDurationAboveZero = errors.New(
"is not a duration above zero, such as 1h or 7d") "is not a duration above zero, such as 1h or 7d")
errNotNumberAboveZero = errors.New( errNotNumberAboveZero = errors.New(
@@ -1601,16 +1601,16 @@ func durationOrDays(value string) (time.Duration, error) {
} }
// parseCoreRuleSetBodyInspectionSize reads the most of a request body the Core Rule Set // parseCoreRuleSetBodyInspectionSize reads the most of a request body the Core Rule Set
// reads: a size as parseSize reads it, or off, but at most 64K, since waf // reads: a size as parseSize reads it, or off, but at most 8K, so that one
// gets no more of a body than that. // request cannot keep the Core Rule Set busy past rproxy's timeout.
func parseCoreRuleSetBodyInspectionSize(value string) (int64, error) { func parseCoreRuleSetBodyInspectionSize(value string) (int64, error) {
limit, err := parseSize(value) limit, err := parseSize(value)
if err != nil { if err != nil {
return 0, err return 0, err
} }
if limit > 64*kibibyte { if limit > 8*kibibyte {
return 0, fmt.Errorf("%q %w", value, errOver64K) return 0, fmt.Errorf("%q %w", value, errOver8K)
} }
return limit, nil return limit, nil
+12 -10
View File
@@ -616,7 +616,7 @@ func TestCoreRuleSetSettings(t *testing.T) {
coreRuleSetScoreThreshold: "10", coreRuleSetScoreThreshold: "10",
coreRuleSetDisabledRules: "942100, 920350", coreRuleSetDisabledRules: "942100, 920350",
coreRuleSetExemptPaths: "/api/, /static/", coreRuleSetExemptPaths: "/api/, /static/",
coreRuleSetBodyInspectionSize: "32K", coreRuleSetBodyInspectionSize: "4K",
exclusionsFile: "/etc/sww/crs-exclusions.conf", exclusionsFile: "/etc/sww/crs-exclusions.conf",
}, },
config.WAF{ config.WAF{
@@ -625,7 +625,7 @@ func TestCoreRuleSetSettings(t *testing.T) {
CoreRuleSetScoreThreshold: 10, CoreRuleSetScoreThreshold: 10,
CoreRuleSetDisabledRules: []int{942100, 920350}, CoreRuleSetDisabledRules: []int{942100, 920350},
CoreRuleSetExemptPaths: []string{"/api/", "/static/"}, CoreRuleSetExemptPaths: []string{"/api/", "/static/"},
CoreRuleSetBodyInspectionSize: 32 << 10, CoreRuleSetBodyInspectionSize: 4 << 10,
CoreRuleSetExclusionsFile: "/etc/sww/crs-exclusions.conf", CoreRuleSetExclusionsFile: "/etc/sww/crs-exclusions.conf",
}, },
}, },
@@ -762,15 +762,15 @@ func TestInvalidAppsStopsTheStart(t *testing.T) {
} }
} }
func TestCoreRuleSetBodyInspectionSizeOf64K(t *testing.T) { func TestCoreRuleSetBodyInspectionSizeOf8K(t *testing.T) {
t.Parallel() t.Parallel()
cfg := wafFromEnvironment( cfg := wafFromEnvironment(
t, t,
environment{coreRuleSetBodyInspectionSize: "64K"}, environment{coreRuleSetBodyInspectionSize: "8K"},
) )
if cfg.CoreRuleSetBodyInspectionSize != 64<<10 { if cfg.CoreRuleSetBodyInspectionSize != 8<<10 {
t.Errorf("64K read as %d", cfg.CoreRuleSetBodyInspectionSize) t.Errorf("8K read as %d", cfg.CoreRuleSetBodyInspectionSize)
} }
} }
@@ -819,11 +819,13 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
coreRuleSetBodyInspectionSize, "128KB", coreRuleSetBodyInspectionSize, "128KB",
`"128KB" is not a size such as 512K, 100M or 5G, or off`, `"128KB" is not a size such as 512K, 100M or 5G, or off`,
}, },
{coreRuleSetBodyInspectionSize, "65K", `"65K" is more than 64K, the most waf ` +
`gets of a body`},
{ {
coreRuleSetBodyInspectionSize, "65537", coreRuleSetBodyInspectionSize, "9K",
`"65537" is more than 64K, the most waf gets of a body`, `"9K" is more than 8K, the most the Core Rule Set can read in time`,
},
{
coreRuleSetBodyInspectionSize, "8193",
`"8193" is more than 8K, the most the Core Rule Set can read in time`,
}, },
} { } {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
+10 -8
View File
@@ -1512,13 +1512,15 @@ const (
wpTags = "/wp-json/wp/v2/tags" wpTags = "/wp-json/wp/v2/tags"
) )
// readingBodiesUpTo64K returns the Core Rule Set as sww runs it by default, // readingBodiesUpTo8K returns the Core Rule Set as sww runs it by default,
// but reading bodies up to the largest SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE. // but reading bodies up to the largest SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE.
func readingBodiesUpTo64K(t *testing.T) *coreruleset.CoreRuleSet { func readingBodiesUpTo8K(t *testing.T) *coreruleset.CoreRuleSet {
t.Helper() t.Helper()
return shared(t, coreruleset.Params{ return shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyInspectionSize: 64<<10 - 1, ParanoiaLevel: 1,
DisabledRules: defaultDisabledRules,
BodyInspectionSize: mostBodyRead,
}) })
} }
@@ -1582,7 +1584,7 @@ func TestWordPressAttacksAreRefused(t *testing.T) {
const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default const threshold = 5 // SWW_WAF_CORE_RULE_SET_SCORE_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t) defaults, bodies := atDefaults(t), readingBodiesUpTo8K(t)
for _, attack := range []string{ for _, attack := range []string{
sqlInjection, sqlInjection,
@@ -1693,7 +1695,7 @@ func wordpressRESTAddresses(route string) []string {
func TestWordPressExclusionsFollowEveryRESTAddress(t *testing.T) { func TestWordPressExclusionsFollowEveryRESTAddress(t *testing.T) {
t.Parallel() t.Parallel()
crs := readingBodiesUpTo64K(t) crs := readingBodiesUpTo8K(t)
content := `{"content":"` + wordpressBlock + `"}` content := `{"content":"` + wordpressBlock + `"}`
for _, route := range []string{ for _, route := range []string{
@@ -1919,7 +1921,7 @@ func TestWordPressCommandAfterASeparatorIsRefusedOnSearchesAndNames(
wantResult(t, crs, get(target+command), matched(932235)) wantResult(t, crs, get(target+command), matched(932235))
} }
bodies := readingBodiesUpTo64K(t) bodies := readingBodiesUpTo8K(t)
for _, p := range wordpressBodyFields(command) { for _, p := range wordpressBodyFields(command) {
r := request{http.MethodPost, p.target, []string{p.contentType}} r := request{http.MethodPost, p.target, []string{p.contentType}}
@@ -2143,7 +2145,7 @@ func TestWordPressBodyFieldsAreCheckedInTheQuery(t *testing.T) {
func TestWordPressBodyNamesAndAddressesAreExcludedOnlyThere(t *testing.T) { func TestWordPressBodyNamesAndAddressesAreExcludedOnlyThere(t *testing.T) {
t.Parallel() t.Parallel()
crs := readingBodiesUpTo64K(t) crs := readingBodiesUpTo8K(t)
for _, v := range wordpressWords() { for _, v := range wordpressWords() {
for _, field := range []string{"title", "excerpt", "name"} { for _, field := range []string{"title", "excerpt", "name"} {
@@ -2262,7 +2264,7 @@ func TestWordPressExclusionsAreNarrow(t *testing.T) {
length = "Content-Length: 2" length = "Content-Length: 2"
) )
crs := readingBodiesUpTo64K(t) crs := readingBodiesUpTo8K(t)
saving := request{ saving := request{
http.MethodPost, http.MethodPost,
wpPost, wpPost,
+345
View File
@@ -0,0 +1,345 @@
package coreruleset_test
import (
"maps"
"net/http"
"net/http/httptest"
"net/netip"
"strconv"
"strings"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
)
// The bounds on what a visitor can send, as SPEC.md gives them.
const (
// lineLimit is the longest request line or header line.
lineLimit = 8 << 10
// headerLimit is the most bytes rproxy reads of a request line and
// headers together.
headerLimit = 32 << 10
// mostCookies and mostHeaderFields are waf's limits on cookies and header
// fields.
mostCookies = 50
mostHeaderFields = 100
// mostBodyRead is the largest SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE.
mostBodyRead = 8 << 10
)
// inspectionTimeLimit is the most time the Core Rule Set may take on one
// request. It is the default of rproxy's SWW_RPROXY_WAF_BACKEND_TIMEOUT_MS, 2
// seconds, less the default of SWW_WAF_ASN_AND_COUNTRY_LOOKUP_TIMEOUT_MS, 800
// milliseconds, which a question can also wait for GeoJS, and less 200
// milliseconds for waf's other checks.
const inspectionTimeLimit = time.Second
// text returns n bytes of letters, digits and spaces, which the Core Rule Set
// takes longer over than one letter repeated.
func text(n int) string {
const words = "select 0x2f from tbl_1 where name like abc_9 "
return strings.Repeat(words, n/len(words)+1)[:n]
}
// visitorRequest is a request a visitor can send, at the most of one or more
// of the bounds above.
type visitorRequest struct {
name string
target string
// header holds the headers besides Host.
header http.Header
// contentType is the body's type, "" for no body.
contentType string
body string
}
// longestTarget returns a target whose request line, "POST <target>
// HTTP/1.1", is lineLimit bytes long.
func longestTarget() string {
const around = len("POST /?q= HTTP/1.1")
return "/?q=" + strings.ReplaceAll(text(lineLimit-around), " ", "+")
}
// cookieLine returns a Cookie value whose line, "Cookie: <value>", is
// lineLimit bytes long, made of count cookies.
func cookieLine(count int) string {
const linePrefix = len("Cookie: ")
cookies := make([]string, count)
for i := range cookies {
cookies[i] = "c" + strconv.Itoa(i) + "="
}
// Each cookie is followed by "; ", but for the last.
room := lineLimit - linePrefix - len(strings.Join(cookies, "; "))
for i := range cookies {
share := room / count
if i == 0 {
share += room % count
}
cookies[i] += strings.ReplaceAll(text(share), " ", "_")
}
return strings.Join(cookies, "; ")
}
// headerValue returns a value whose line, "<name>: <value>", is n bytes long.
func headerValue(name string, n int) string {
return text(n - len(name) - len(": "))
}
// formFields returns a form body of mostBodyRead bytes holding count fields.
func formFields(count int) string {
fields := make([]string, count)
for i := range fields {
fields[i] = "f" + strconv.Itoa(i) + "="
}
room := mostBodyRead - len(strings.Join(fields, "&"))
for i := range fields {
share := room / count
if i == 0 {
share += room % count
}
fields[i] += strings.ReplaceAll(text(share), " ", "+")
}
return strings.Join(fields, "&")
}
// multipartBody returns a multipart body of mostBodyRead bytes, with the
// boundary "b", holding fields of about size bytes each.
func multipartBody(size int) string {
var body strings.Builder
const end = "--b--\r\n"
for i := 0; ; i++ {
head := "--b\r\nContent-Disposition: form-data; name=\"f" +
strconv.Itoa(i) + "\"\r\n\r\n"
room := mostBodyRead - body.Len() - len(
end,
) - len(
head,
) - len(
"\r\n",
)
if room <= 0 {
break
}
body.WriteString(head + text(min(size, room)) + "\r\n")
}
body.WriteString(end)
return body.String()
}
// jsonArrayOfMostElements returns a JSON array of at most mostBodyRead bytes
// holding as many elements as fit, each the number 1.
func jsonArrayOfMostElements() string {
return "[1" + strings.Repeat(
",1",
(mostBodyRead-len("[1]"))/len(",1"),
) + "]"
}
// The kinds of body the Core Rule Set reads, and the header that names a
// request's cookies.
const (
formType = "application/x-www-form-urlencoded"
jsonType = "application/json"
multipartType = "multipart/form-data; boundary=b"
cookieHeader = "Cookie"
)
// fullLine is the length of each of the request line and the three or four
// header lines that together fill rproxy's limit, with room for the line
// endings and the other headers.
const fullLine = lineLimit - 64
// worstCaseRequests are, for each kind of thing a visitor sends, the requests
// that keep the Core Rule Set busiest, within the bounds above. The last
// holds the most of everything at once: Cookie lines keep it busier than
// other header lines, and the JSON body of the most elements keeps it busier
// than the other bodies.
func worstCaseRequests() []visitorRequest {
fullCookieLine := cookieLine(1)[:fullLine-len("Cookie: ")]
return append(
append(worstCaseHeaders(), worstCaseBodies()...),
visitorRequest{
name: "the longest request line, three Cookie lines and body",
target: longestTarget()[:fullLine-len("POST HTTP/1.1")],
header: http.Header{cookieHeader: {
fullCookieLine, fullCookieLine, fullCookieLine,
}},
contentType: jsonType, body: jsonArrayOfMostElements(),
},
)
}
// worstCaseHeaders are the requests of worstCaseRequests with no body.
func worstCaseHeaders() []visitorRequest {
headerFields := http.Header{}
for i := range mostHeaderFields - 1 {
name := "X-Field-" + strconv.Itoa(i)
headerFields.Set(
name,
headerValue(name, headerLimit/mostHeaderFields),
)
}
return []visitorRequest{
{name: "the longest request line", target: longestTarget()},
{
name: "the longest request line, as query parameters",
target: "/?" + strings.Repeat(
"a=1&",
(lineLimit-len("POST /? HTTP/1.1"))/len("a=1&"),
),
},
{
name: "the longest Cookie line, as one cookie",
target: "/",
header: http.Header{cookieHeader: {cookieLine(1)}},
},
{
name: "the longest Cookie line, as the most cookies",
target: "/",
header: http.Header{cookieHeader: {cookieLine(mostCookies)}},
},
{name: "the most header fields", target: "/", header: headerFields},
{
name: "the longest header lines",
target: "/",
header: http.Header{
"User-Agent": {headerValue("User-Agent", fullLine)},
"Referer": {headerValue("Referer", fullLine)},
"Accept": {headerValue("Accept", fullLine)},
"X-Long": {headerValue("X-Long", fullLine)},
},
},
}
}
// worstCaseBodies are the requests of worstCaseRequests whose body holds
// the most the Core Rule Set reads.
func worstCaseBodies() []visitorRequest {
const nested = (mostBodyRead - len(`{"a":1}`)) / len(`{"a":}`)
return []visitorRequest{
{
name: "a form body of one field", target: "/",
contentType: formType, body: formFields(1),
},
{
name: "a form body of the most fields Coraza keeps", target: "/",
contentType: formType, body: formFields(1000),
},
{
name: "a JSON body of one string", target: "/",
contentType: jsonType,
body: `{"a":"` + text(
mostBodyRead-len(`{"a":""}`),
) + `"}`,
},
{
name: "a JSON body of the most elements", target: "/",
contentType: jsonType, body: jsonArrayOfMostElements(),
},
{
name: "a JSON body nested deep", target: "/",
contentType: jsonType,
body: strings.Repeat(`{"a":`, nested) + "1" +
strings.Repeat("}", nested),
},
{
name: "an XML body of many elements", target: "/",
contentType: "application/xml",
body: "<a>" + strings.Repeat(
"<b>x</b>",
(mostBodyRead-len("<a></a>"))/len("<b>x</b>"),
) + "</a>",
},
{
name: "a multipart body of one field", target: "/",
contentType: multipartType, body: multipartBody(mostBodyRead),
},
{
name: "a multipart body of many fields", target: "/",
contentType: multipartType, body: multipartBody(1),
},
}
}
// inspectionTime returns how long crs takes to inspect tc, and fails t unless
// it reads all of tc's body.
func inspectionTime(
t *testing.T, crs *coreruleset.CoreRuleSet, tc visitorRequest,
) time.Duration {
t.Helper()
req := httptest.NewRequestWithContext(
t.Context(), http.MethodPost, tc.target, nil,
)
maps.Copy(req.Header, tc.header)
if tc.contentType != "" {
req.Header.Set("Content-Type", tc.contentType)
}
began := time.Now()
_, read, _ := crs.Inspect(
req, netip.MustParseAddr("192.0.2.1"), strings.NewReader(tc.body),
)
took := time.Since(began)
if string(read) != tc.body {
t.Errorf("read %d bytes of %d", len(read), len(tc.body))
}
return took
}
// TestTheCoreRuleSetInspectsEachWorstCaseRequestInTime runs one at a time, so
// that the tests running in parallel do not slow it. Other work on the host
// can still slow one try, so a request fails only when three tries in a row
// take too long.
//
//nolint:paralleltest // one at a time, as above
func TestTheCoreRuleSetInspectsEachWorstCaseRequestInTime(t *testing.T) {
const tries = 3
// The settings that make the Core Rule Set slowest: the highest paranoia
// level, no rule switched off, and the most of a body read.
crs := shared(t, coreruleset.Params{
ParanoiaLevel: 4, BodyInspectionSize: mostBodyRead,
})
for _, tc := range worstCaseRequests() {
t.Run(tc.name, func(t *testing.T) {
var took time.Duration
for range tries {
took = inspectionTime(t, crs, tc)
if took <= inspectionTimeLimit {
return
}
}
t.Errorf(
"took %s on the last of %d tries, over %s",
took, tries, inspectionTimeLimit,
)
})
}
}
+7 -2
View File
@@ -21,7 +21,12 @@ const (
// the app. // the app.
ActionForward = "forward" ActionForward = "forward"
// ActionRequestHeaderTooLarge is a request over the header limits. // ActionRequestLineTooLong is a request whose request line is too long for
// 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
@@ -143,7 +148,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, // ActionBlockedByCoreRuleSet, or, in waf, ActionRequestLineTooLong or
// 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
+82
View File
@@ -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
}
}
+142
View File
@@ -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")
}
}
+23
View File
@@ -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)
}
+8 -5
View File
@@ -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)
+134
View File
@@ -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
View File
@@ -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.
+11 -1
View File
@@ -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
+130 -1
View File
@@ -39,6 +39,7 @@ 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.
@@ -104,11 +105,139 @@ 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",
`{"fields": 101, "fields_limit": 100, "cookies": 0, "cookies_limit": 50}`, `{"longest_header_line_bytes": 40, "header_line_bytes_limit": 8192,
"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}`,
) )
} }
+52 -7
View File
@@ -10,16 +10,22 @@ 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,
// Real clients never reach them. // and the longest its request line and each header line may be, in bytes, not
// 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 more than maxHeaderFields header fields // headerLimits refuses a request with a request line longer than maxLineBytes,
// or more than maxCookies cookies, with 431. Its figures are the request's // with 414. It refuses a request with a header line longer than maxLineBytes,
// header fields and cookies, and the limit of each. // more than maxHeaderFields header fields or more than maxCookies cookies, with
// 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" }
@@ -29,20 +35,59 @@ 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{
"fields": fields, "fields_limit": maxHeaderFields, "longest_header_line_bytes": longest,
"header_line_bytes_limit": maxLineBytes,
"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 counts, and a request it refuses causes no // header_limits comes first: it only measures and counts, and a request it
// other work. // refuses causes no other work.
func beforeLookup() []heuristic { func beforeLookup() []heuristic {
return []heuristic{ return []heuristic{
headerLimits{}, headerLimits{},
+8 -14
View File
@@ -228,12 +228,9 @@ func newReputation(
// without the rules SWW_WAF_CORE_RULE_SET_DISABLED_RULES switches off, reading bodies // without the rules SWW_WAF_CORE_RULE_SET_DISABLED_RULES switches off, reading bodies
// up to SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE, with the exclusions in // up to SWW_WAF_CORE_RULE_SET_BODY_INSPECTION_SIZE, with the exclusions in
// SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_WAF_APPS, // SWW_WAF_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_WAF_APPS,
// or nil while SWW_WAF_CORE_RULE_SET_MODE is off. It // or nil while SWW_WAF_CORE_RULE_SET_MODE is off. An exclusions file that
// reads at most QuestionBodyMaxBytes less one byte: a question's body of // coreruleset.New refuses is an error in every mode. While the mode is off, a
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a // set file is loaded with the Core Rule Set only to check it.
// JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is
// an error in every mode. While the mode is off, a set file is loaded with the
// Core Rule Set only to check it.
func NewCoreRuleSet(cfg *config.WAF) (*coreruleset.CoreRuleSet, error) { func NewCoreRuleSet(cfg *config.WAF) (*coreruleset.CoreRuleSet, error) {
if cfg.CoreRuleSetMode == config.CoreRuleSetModeOff && if cfg.CoreRuleSetMode == config.CoreRuleSetModeOff &&
cfg.CoreRuleSetExclusionsFile == "" { cfg.CoreRuleSetExclusionsFile == "" {
@@ -241,14 +238,11 @@ func NewCoreRuleSet(cfg *config.WAF) (*coreruleset.CoreRuleSet, error) {
} }
coreRuleSet, err := coreruleset.New(coreruleset.Params{ coreRuleSet, err := coreruleset.New(coreruleset.Params{
ParanoiaLevel: cfg.CoreRuleSetParanoiaLevel, ParanoiaLevel: cfg.CoreRuleSetParanoiaLevel,
DisabledRules: cfg.CoreRuleSetDisabledRules, DisabledRules: cfg.CoreRuleSetDisabledRules,
BodyInspectionSize: min( BodyInspectionSize: cfg.CoreRuleSetBodyInspectionSize,
cfg.CoreRuleSetBodyInspectionSize, ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
QuestionBodyMaxBytes-1, Apps: cfg.Apps,
),
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
Apps: cfg.Apps,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
+3 -1
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 431 for the header limits. In observe mode nothing is refused, // Rule Set, or 414 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,6 +196,8 @@ 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,
+10 -5
View File
@@ -1690,7 +1690,12 @@ 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)
@@ -1782,7 +1787,7 @@ func TestCoreRuleSetReadsTheBodyAQuestionCarries(t *testing.T) {
t.Parallel() t.Parallel()
s := startWAF(t, "", map[string]string{ s := startWAF(t, "", map[string]string{
coreRuleSetMode: block, coreRuleSetBodyInspectionSize: "64K", coreRuleSetMode: block, coreRuleSetBodyInspectionSize: "8K",
}) })
ask := func(contentType, body string) answer { ask := func(contentType, body string) answer {
@@ -1797,10 +1802,10 @@ func TestCoreRuleSetReadsTheBodyAQuestionCarries(t *testing.T) {
got := ask(formData, "id=1'%20OR%20'1'='1") got := ask(formData, "id=1'%20OR%20'1'='1")
wantDisposition(t, got, http.StatusForbidden, waf.DispositionDeny) wantDisposition(t, got, http.StatusForbidden, waf.DispositionDeny)
// The first 64 KiB of a longer JSON body cannot be parsed, which is no // The first 8 KiB of a longer JSON body cannot be parsed, which is no
// attack, so the Core Rule Set does not read it. // attack, so the Core Rule Set does not read it.
cut := `{"text":"` + strings.Repeat("x", waf.QuestionBodyMaxBytes) cut := `{"text":"` + strings.Repeat("x", 8<<10)
got = ask("application/json", cut[:waf.QuestionBodyMaxBytes]) got = ask("application/json", cut)
wantDisposition(t, got, http.StatusOK, waf.DispositionAllow) wantDisposition(t, got, http.StatusOK, waf.DispositionAllow)
} }