Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec879925b2 |
No files matched your search
+16
-25
@@ -87,7 +87,7 @@ COPY . .
|
|||||||
# .git present, a version that is still empty, dev or unknown fails the
|
# .git present, a version that is still empty, dev or unknown fails the
|
||||||
# build: git is missing or could not read the checkout. The no_fs_access
|
# build: git is missing or could not read the checkout. The no_fs_access
|
||||||
# tag keeps Coraza from writing the files of a multipart body to the
|
# tag keeps Coraza from writing the files of a multipart body to the
|
||||||
# system's temporary directory, since sww writes only to its state
|
# system's temporary directory, since smallwebwaf writes only to its state
|
||||||
# directory.
|
# directory.
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||||
@@ -99,7 +99,7 @@ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
|||||||
fi; \
|
fi; \
|
||||||
CGO_ENABLED=0 go build -tags no_fs_access -trimpath \
|
CGO_ENABLED=0 go build -tags no_fs_access -trimpath \
|
||||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
-o /usr/local/bin/sww ./cmd/sww
|
-o /usr/local/bin/smallwebwaf ./cmd/smallwebwaf
|
||||||
|
|
||||||
# runsvinit, the image's entrypoint, built at the last commit of its
|
# runsvinit, the image's entrypoint, built at the last commit of its
|
||||||
# archived repository. It has no go.mod, and `go build` of its directory
|
# archived repository. It has no go.mod, and `go build` of its directory
|
||||||
@@ -118,7 +118,7 @@ RUN git checkout --quiet --detach b4b2c785308b1ce785b6155c7fe5f16879080193 \
|
|||||||
-o /usr/local/bin/runsvinit .
|
-o /usr/local/bin/runsvinit .
|
||||||
|
|
||||||
# The image, described under "Deployment" in SPEC.md. It runs rproxy unless
|
# The image, described under "Deployment" in SPEC.md. It runs rproxy unless
|
||||||
# SWW_COMMAND says discriminator, and an app's Dockerfile can build FROM it.
|
# SMALLWEBWAF_COMMAND says servewaf, and an app's Dockerfile can build FROM it.
|
||||||
# It is the last stage, so a plain `docker build .` builds it.
|
# It is the last stage, so a plain `docker build .` builds it.
|
||||||
#
|
#
|
||||||
# ubuntu 26.04, 2026-09-27
|
# ubuntu 26.04, 2026-09-27
|
||||||
@@ -136,19 +136,10 @@ RUN mkdir /etc/sysusers.d \
|
|||||||
# ca-certificates, nix-bin and runit, from Ubuntu's archive. apt fetches
|
# ca-certificates, nix-bin and runit, from Ubuntu's archive. apt fetches
|
||||||
# them over plain HTTP and checks each one against the archive's signed
|
# them over plain HTTP and checks each one against the archive's signed
|
||||||
# package lists, so no CA certificates are needed.
|
# package lists, so no CA certificates are needed.
|
||||||
#
|
RUN apt-get update \
|
||||||
# nix-bin depends on xz-utils, whose install links man pages with
|
|
||||||
# update-alternatives. The image's dpkg config drops man pages, so each
|
|
||||||
# link warns. z-man, read after /etc/dpkg/dpkg.cfg.d/excludes, re-includes
|
|
||||||
# the man1 pages for this install alone; the same step deletes them again,
|
|
||||||
# so no man page or package is added.
|
|
||||||
RUN echo 'path-include=/usr/share/man/man1/*' > /etc/dpkg/dpkg.cfg.d/z-man \
|
|
||||||
&& apt-get update \
|
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \
|
||||||
ca-certificates nix-bin runit \
|
ca-certificates nix-bin runit \
|
||||||
&& rm -rf /var/lib/apt/lists/* /etc/dpkg/dpkg.cfg.d/z-man \
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
&& find /usr/share/man/man1 ! -type d -delete \
|
|
||||||
&& find /etc/alternatives -type l -xtype l -delete
|
|
||||||
|
|
||||||
# Nix run by root expects a group of build users, which nix-bin does not
|
# Nix run by root expects a group of build users, which nix-bin does not
|
||||||
# create; with the setting empty, root's builds run without them.
|
# create; with the setting empty, root's builds run without them.
|
||||||
@@ -175,27 +166,27 @@ RUN curl -fsSL -o /tmp/nixexprs.tar.xz \
|
|||||||
ENV PATH=${PATH}:/nix/var/nix/profiles/default/bin
|
ENV PATH=${PATH}:/nix/var/nix/profiles/default/bin
|
||||||
|
|
||||||
COPY --from=runsvinit /usr/local/bin/runsvinit /usr/local/bin/runsvinit
|
COPY --from=runsvinit /usr/local/bin/runsvinit /usr/local/bin/runsvinit
|
||||||
COPY --from=builder /usr/local/bin/sww /usr/local/bin/sww
|
COPY --from=builder /usr/local/bin/smallwebwaf /usr/local/bin/smallwebwaf
|
||||||
|
|
||||||
# 65532 is above the uids Ubuntu keeps for system users, which end at
|
# 65532 is above the uids Ubuntu keeps for system users, which end at
|
||||||
# 999; useradd warns about it unless --key raises that end for this call.
|
# 999; useradd warns about it unless --key raises that end for this call.
|
||||||
RUN groupadd --system --gid 65532 sww \
|
RUN groupadd --system --gid 65532 smallwebwaf \
|
||||||
&& useradd --system --key SYS_UID_MAX=65532 --uid 65532 \
|
&& useradd --system --key SYS_UID_MAX=65532 --uid 65532 \
|
||||||
--gid sww --no-create-home --shell /usr/sbin/nologin \
|
--gid smallwebwaf --no-create-home --shell /usr/sbin/nologin \
|
||||||
sww
|
smallwebwaf
|
||||||
|
|
||||||
# The state files' directory, SWW_STATE_DIR by default, where a volume
|
# The state files' directory, SWWAF_STATE_DIR by default, where a volume
|
||||||
# is mounted to keep them across deploys. The run script gives it to the
|
# is mounted to keep them across deploys. The run script gives it to the
|
||||||
# sww user at each start of discriminator.
|
# smallwebwaf user at each start of servewaf.
|
||||||
RUN mkdir /var/lib/sww
|
RUN mkdir /var/lib/smallwebwaf
|
||||||
|
|
||||||
# The default rule file, in SWW_RULES_DIR by default, where a Dockerfile
|
# The default rule file, in SWWAF_RULES_DIR by default, where a Dockerfile
|
||||||
# built FROM this image can copy rule files of its own beside it.
|
# built FROM this image can copy rule files of its own beside it.
|
||||||
COPY share/rules.d/00-default.rules /etc/sww/rules.d/00-default.rules
|
COPY share/rules.d/00-default.rules /etc/smallwebwaf/rules.d/00-default.rules
|
||||||
|
|
||||||
# runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv
|
# runsvinit starts runit's runsvdir on /etc/service, where Ubuntu's sv
|
||||||
# looks too.
|
# looks too.
|
||||||
COPY --chmod=755 share/sww.run /etc/service/sww/run
|
COPY --chmod=755 share/smallwebwaf.run /etc/service/smallwebwaf/run
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
@@ -203,6 +194,6 @@ EXPOSE 8080
|
|||||||
# check runs every second from the start until it first passes, for up
|
# check runs every second from the start until it first passes, for up
|
||||||
# to a minute, and every 30 seconds after that.
|
# to a minute, and every 30 seconds after that.
|
||||||
HEALTHCHECK --start-period=1m --start-interval=1s \
|
HEALTHCHECK --start-period=1m --start-interval=1s \
|
||||||
CMD ["/usr/local/bin/sww", "healthcheck"]
|
CMD ["/usr/local/bin/smallwebwaf", "healthcheck"]
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/runsvinit"]
|
ENTRYPOINT ["/usr/local/bin/runsvinit"]
|
||||||
+44
-56
@@ -12,8 +12,8 @@ configured only by environment variables, that provides:
|
|||||||
- R1: rate limits per minute, per hour and per day
|
- R1: rate limits per minute, per hour and per day
|
||||||
- R2: netblocks that bypass rate limiting
|
- R2: netblocks that bypass rate limiting
|
||||||
- R3: alerts on attacks through webhook, Slack or ntfy
|
- R3: alerts on attacks through webhook, Slack or ntfy
|
||||||
- R4: alerts on anomalies: requests made or bytes transferred per minute or
|
- R4: alerts on anomalies: requests or bytes per minute or hour, for given IPs
|
||||||
hour, for given IPs or netblocks, crossing a threshold
|
or netblocks, crossing a threshold
|
||||||
- R5: temporary blocks for abusers, permanent bans for repeat offenders
|
- R5: temporary blocks for abusers, permanent bans for repeat offenders
|
||||||
- R6: one or more RBLs or IP reputation APIs
|
- R6: one or more RBLs or IP reputation APIs
|
||||||
- R7: AS number lookup
|
- R7: AS number lookup
|
||||||
@@ -24,19 +24,14 @@ configured only by environment variables, that provides:
|
|||||||
|
|
||||||
## Verdict
|
## Verdict
|
||||||
|
|
||||||
Nothing existing is suitable. No surveyed tool meets R8, which asks for limits
|
Nothing existing is suitable. No surveyed tool meets R8 (scaling limits by AS
|
||||||
scaled by AS number or country. None meets R4's alerts on bytes transferred. The
|
number or country) or the bytes half of R4 at all, and the tools that come
|
||||||
tools that come closest on the rest fail R10: they need several containers, YAML
|
closest on the rest fail R10: they need several containers, YAML or a web UI.
|
||||||
or a web UI. The recommendation is to build a small single-binary sidecar;
|
The recommendation is to build a small single-binary sidecar; `SPEC.md` in this
|
||||||
`SPEC.md` in this directory begins that spec.
|
directory begins that spec. It reuses the mature parts of the surveyed field as
|
||||||
|
libraries and data sources (Coraza and the OWASP Core Rule Set for attack
|
||||||
The sidecar reuses the mature parts of the surveyed field instead of
|
detection, DNSBLs, AbuseIPDB, optionally a CrowdSec decision feed) instead of
|
||||||
reimplementing them. It takes these as libraries and data sources:
|
reimplementing them.
|
||||||
|
|
||||||
- Coraza and the OWASP Core Rule Set, for attack detection
|
|
||||||
- DNSBLs
|
|
||||||
- AbuseIPDB
|
|
||||||
- optionally, a CrowdSec decision feed
|
|
||||||
|
|
||||||
Closest existing options, and why each still falls short:
|
Closest existing options, and why each still falls short:
|
||||||
|
|
||||||
@@ -50,16 +45,13 @@ Closest existing options, and why each still falls short:
|
|||||||
external blacklist URLs, optional CrowdSec), R7 partly (AS number used for
|
external blacklist URLs, optional CrowdSec), R7 partly (AS number used for
|
||||||
blacklist and whitelist decisions), R9 (ModSecurity with the Core Rule
|
blacklist and whitelist decisions), R9 (ModSecurity with the Core Rule
|
||||||
Set, or Coraza plugin), env-var settings.
|
Set, or Coraza plugin), env-var settings.
|
||||||
- Fails:
|
- Fails: R8 (AS number and country can only allow or deny, never scale a
|
||||||
- R8: AS number and country can only allow or deny, never scale a limit.
|
limit), R4 (no volume or byte threshold alerts in the free edition;
|
||||||
- R4: no alerts on request-count or byte thresholds in the free edition;
|
reporting is a paid feature), R5 escalation, and R10 in spirit: since 1.6
|
||||||
reporting is a paid feature.
|
it needs a `bunkerweb` container plus a `bw-scheduler` container and a
|
||||||
- R5: no escalation for repeat offenders.
|
database, or the all-in-one image that bundles nginx, scheduler, UI and
|
||||||
- R10: since 1.6 it is not one plain sidecar. It needs a `bunkerweb`
|
Redis in one container. It is designed to be the front door for many
|
||||||
container, a `bw-scheduler` container and a database, or the
|
sites, not a per-app sidecar. AGPL-3.0.
|
||||||
all-in-one image that bundles nginx, the scheduler, the UI and Redis.
|
|
||||||
- It is designed as the proxy in front of many sites, not as a sidecar for
|
|
||||||
one app. AGPL-3.0.
|
|
||||||
- Unverified: whether several rates (minute, hour, day) can be stacked on
|
- Unverified: whether several rates (minute, hour, day) can be stacked on
|
||||||
the same URL pattern; the documented design is one rate per URL pattern.
|
the same URL pattern; the documented design is one rate per URL pattern.
|
||||||
- CrowdSec is the strongest detection and ban engine, but it is not a proxy.
|
- CrowdSec is the strongest detection and ban engine, but it is not a proxy.
|
||||||
@@ -74,20 +66,16 @@ Closest existing options, and why each still falls short:
|
|||||||
listed AS numbers is possible, but each is a hand-written YAML scenario,
|
listed AS numbers is possible, but each is a hand-written YAML scenario,
|
||||||
it reacts after the fact by banning, and it is not an inline limiter that
|
it reacts after the fact by banning, and it is not an inline limiter that
|
||||||
answers 429.
|
answers 429.
|
||||||
- Fails:
|
- Fails: R10 (needs the security engine container with persistent state, log
|
||||||
- R10, because it needs:
|
acquisition from traefik, a bouncer such as the traefik plugin, and YAML
|
||||||
- the security engine container with persistent state
|
for acquisition, profiles, scenarios and notifications), bytes half of R4.
|
||||||
- log acquisition from traefik
|
|
||||||
- a bouncer such as the traefik plugin
|
|
||||||
- YAML for acquisition, profiles, scenarios and notifications
|
|
||||||
- R4's alerts on bytes.
|
|
||||||
- CrowdSec plus traefik's own `rateLimit` middleware is the best combination
|
- CrowdSec plus traefik's own `rateLimit` middleware is the best combination
|
||||||
with no new code. It gives inline limiting (one window per middleware, keyed
|
with no new code. It gives inline limiting (one window per middleware, keyed
|
||||||
by IP, with `sourceCriterion` exclusions), bans with escalation, alerts and
|
by IP, with `sourceCriterion` exclusions), bans with escalation, alerts and
|
||||||
reputation lookups. It still fails R8, R4's alerts on bytes, and R10 entirely:
|
reputation. It still fails R8, the bytes half of R4, R1's three stacked
|
||||||
the configuration lives in traefik labels and CrowdSec YAML on each host, not
|
windows only by chaining three middlewares per router, and R10 entirely: the
|
||||||
in one sidecar's environment. It meets R1's three windows only by chaining
|
configuration lives in traefik labels and CrowdSec YAML on each host, not in
|
||||||
three middlewares per router.
|
one sidecar's environment.
|
||||||
|
|
||||||
## Candidate by candidate
|
## Candidate by candidate
|
||||||
|
|
||||||
@@ -99,10 +87,10 @@ Closest existing options, and why each still falls short:
|
|||||||
cached, streamed decision list (recommended), standalone against the
|
cached, streamed decision list (recommended), standalone against the
|
||||||
central API, or AppSec only. Supports captcha remediation.
|
central API, or AppSec only. Supports captcha remediation.
|
||||||
- Covers: R5, R6, R9, R3 and R7 through the engine (see Verdict).
|
- Covers: R5, R6, R9, R3 and R7 through the engine (see Verdict).
|
||||||
- Misses: the plugin itself does no rate limiting; R8; R4's alerts on bytes.
|
- Misses: the plugin itself does no rate limiting; R8; R4 bytes.
|
||||||
- Configuration: traefik static config to load the plugin, dynamic config or
|
- Configuration: traefik static config to load the plugin, dynamic config or
|
||||||
labels for the middleware, CrowdSec YAML for everything else.
|
labels for the middleware, CrowdSec YAML for everything else.
|
||||||
- Sidecar fit: no. It runs inside traefik and needs a separate engine
|
- Sidecar fit: no. It lives inside traefik, plus a separate engine
|
||||||
container.
|
container.
|
||||||
- Maturity: widely used (about 900 stars, listed in the traefik plugin
|
- Maturity: widely used (about 900 stars, listed in the traefik plugin
|
||||||
catalog, documented by CrowdSec itself), actively maintained. Traefik
|
catalog, documented by CrowdSec itself), actively maintained. Traefik
|
||||||
@@ -127,7 +115,7 @@ Closest existing options, and why each still falls short:
|
|||||||
- What it is: nginx-derived (Tengine) reverse proxy with a proprietary
|
- What it is: nginx-derived (Tengine) reverse proxy with a proprietary
|
||||||
semantic detection engine and a web console. About 22,000 stars.
|
semantic detection engine and a web console. About 22,000 stars.
|
||||||
- Covers: R9 well, basic per-IP rate limiting with block duration, bot
|
- Covers: R9 well, basic per-IP rate limiting with block duration, bot
|
||||||
challenge, authentication in front of the app.
|
challenge, authentication gate.
|
||||||
- Misses: R8, R7, R4; R1 has one window per rule. Country blocking, alert
|
- Misses: R8, R7, R4; R1 has one window per rule. Country blocking, alert
|
||||||
messages, syslog forwarding and the better reputation database are paid
|
messages, syslog forwarding and the better reputation database are paid
|
||||||
(Lite about 10 USD per month, Pro about 100 USD per month); the free
|
(Lite about 10 USD per month, Pro about 100 USD per month); the free
|
||||||
@@ -140,7 +128,7 @@ Closest existing options, and why each still falls short:
|
|||||||
- Coraza (`corazawaf/coraza`) and Coraza-based proxies
|
- Coraza (`corazawaf/coraza`) and Coraza-based proxies
|
||||||
- What it is: a Go library that implements the ModSecurity rule language and
|
- What it is: a Go library that implements the ModSecurity rule language and
|
||||||
runs the OWASP Core Rule Set. OWASP project, actively maintained, the
|
runs the OWASP Core Rule Set. OWASP project, actively maintained, the
|
||||||
successor to ModSecurity, which is now in maintenance only.
|
successor path now that ModSecurity is in maintenance only.
|
||||||
- Packagings: `coraza-caddy` (Caddy module), `coraza-spoa` (HAProxy),
|
- Packagings: `coraza-caddy` (Caddy module), `coraza-spoa` (HAProxy),
|
||||||
`coraza-proxy-wasm` (Envoy), a traefik WASM plugin, and
|
`coraza-proxy-wasm` (Envoy), a traefik WASM plugin, and
|
||||||
`coreruleset/coraza-crs-docker` (Caddy plus Coraza plus the Core Rule Set,
|
`coreruleset/coraza-crs-docker` (Caddy plus Coraza plus the Core Rule Set,
|
||||||
@@ -154,7 +142,7 @@ Closest existing options, and why each still falls short:
|
|||||||
- ModSecurity Core Rule Set containers (`owasp/modsecurity-crs`)
|
- ModSecurity Core Rule Set containers (`owasp/modsecurity-crs`)
|
||||||
- What it is: official images of Apache or nginx with ModSecurity and the
|
- What it is: official images of Apache or nginx with ModSecurity and the
|
||||||
Core Rule Set, as a reverse proxy configured by env vars (`BACKEND`,
|
Core Rule Set, as a reverse proxy configured by env vars (`BACKEND`,
|
||||||
paranoia level, anomaly thresholds, many more). Current images contain
|
paranoia level, anomaly thresholds, many more). Current images carry
|
||||||
ModSecurity 2.9.x on Apache and Core Rule Set 4.x.
|
ModSecurity 2.9.x on Apache and Core Rule Set 4.x.
|
||||||
- Covers: R9, and R10 (single container, env vars, one backend).
|
- Covers: R9, and R10 (single container, env vars, one backend).
|
||||||
- Misses: R1 to R8.
|
- Misses: R1 to R8.
|
||||||
@@ -164,8 +152,8 @@ Closest existing options, and why each still falls short:
|
|||||||
- Anubis (`TecharoHQ/anubis`), 1.27 current
|
- Anubis (`TecharoHQ/anubis`), 1.27 current
|
||||||
- What it is: a single-binary reverse proxy that makes browsers solve a
|
- What it is: a single-binary reverse proxy that makes browsers solve a
|
||||||
proof-of-work challenge before passing them to `TARGET`. Aimed at
|
proof-of-work challenge before passing them to `TARGET`. Aimed at
|
||||||
scrapers, which are likely a large share of the unwanted traffic on a
|
scrapers, which is likely a large share of unwanted traffic on a public
|
||||||
public code forge.
|
code forge.
|
||||||
- Covers: R10 well (one container, env vars for listener, target,
|
- Covers: R10 well (one container, env vars for listener, target,
|
||||||
difficulty, cookies). Policy rules can match on path, user agent, headers,
|
difficulty, cookies). Policy rules can match on path, user agent, headers,
|
||||||
IP ranges, and with the vendor's hosted data service also AS number and
|
IP ranges, and with the vendor's hosted data service also AS number and
|
||||||
@@ -192,13 +180,13 @@ Closest existing options, and why each still falls short:
|
|||||||
temporary ban, no escalation, state lost on traefik restart) and R2
|
temporary ban, no escalation, state lost on traefik restart) and R2
|
||||||
(allowlist). Nothing else.
|
(allowlist). Nothing else.
|
||||||
- `juitde/traefik-plugin-fail2ban`, fail2ban connector plugins, GeoBlock
|
- `juitde/traefik-plugin-fail2ban`, fail2ban connector plugins, GeoBlock
|
||||||
plugins (country allow or deny through an external lookup API): each adds
|
plugins (country allow or deny through an external lookup API): same
|
||||||
one small feature, is configured in traefik and keeps its state in memory.
|
shape, each one small feature, configured in traefik, state in memory.
|
||||||
- Sidecar fit: no; they live inside traefik.
|
- Sidecar fit: no; they live inside traefik.
|
||||||
- Traefik built-in middlewares
|
- Traefik built-in middlewares
|
||||||
- `rateLimit` (one average-and-burst window per middleware, optional Redis
|
- `rateLimit` (one average-and-burst window per middleware, optional Redis
|
||||||
in traefik 3.x), `inFlightReq`, `ipAllowList`. No bans, alerts, reputation
|
in traefik 3.x), `inFlightReq`, `ipAllowList`. No bans, alerts, reputation
|
||||||
lookups or AS number lookups.
|
or AS number awareness.
|
||||||
- caddy-waf (`fabriziosalmi/caddy-waf`), 0.4.x
|
- caddy-waf (`fabriziosalmi/caddy-waf`), 0.4.x
|
||||||
- What it is: a Caddy module with regex rules and anomaly scoring, per-IP
|
- What it is: a Caddy module with regex rules and anomaly scoring, per-IP
|
||||||
and per-path rate limiting with one configurable window, IP and DNS
|
and per-path rate limiting with one configurable window, IP and DNS
|
||||||
@@ -210,12 +198,12 @@ Closest existing options, and why each still falls short:
|
|||||||
- open-appsec (Check Point)
|
- open-appsec (Check Point)
|
||||||
- Machine-learning WAF agent attached to nginx, Kong, Envoy or similar, with
|
- Machine-learning WAF agent attached to nginx, Kong, Envoy or similar, with
|
||||||
a declarative policy file or the vendor's cloud console. Covers R9 only;
|
a declarative policy file or the vendor's cloud console. Covers R9 only;
|
||||||
rate limiting and richer features are in paid tiers. It attaches to an
|
rate limiting and richer features are in paid tiers. Not a sidecar in the
|
||||||
existing proxy, so it is not the sidecar R10 asks for.
|
required sense.
|
||||||
- iocaine
|
- iocaine
|
||||||
- Serves generated garbage pages to clients the fronting proxy classifies as
|
- Serves generated garbage pages to clients the fronting proxy classifies as
|
||||||
scrapers. Not a limiter, WAF or ban tool; out of scope, and only of
|
scrapers. Not a limiter, WAF or ban tool; out of scope except as a
|
||||||
passing interest for scraper traffic.
|
curiosity for scraper traffic.
|
||||||
- Pangolin
|
- Pangolin
|
||||||
- A tunnelled access platform that bundles traefik and optionally CrowdSec.
|
- A tunnelled access platform that bundles traefik and optionally CrowdSec.
|
||||||
Replaces the ingress rather than adding a sidecar; out of scope.
|
Replaces the ingress rather than adding a sidecar; out of scope.
|
||||||
@@ -226,17 +214,17 @@ Closest existing options, and why each still falls short:
|
|||||||
Everyone else offers one window per rule or none.
|
Everyone else offers one window per rule or none.
|
||||||
- R2 bypass netblocks: BunkerWeb, CrowdSec, traefik, fail2ban plugins.
|
- R2 bypass netblocks: BunkerWeb, CrowdSec, traefik, fail2ban plugins.
|
||||||
- R3 alerts: CrowdSec (best), BunkerWeb (denied requests only), SafeLine (paid).
|
- R3 alerts: CrowdSec (best), BunkerWeb (denied requests only), SafeLine (paid).
|
||||||
- R4 alerts on request counts and bytes: none. CrowdSec can approximate alerts
|
- R4 volume and byte anomalies: none. CrowdSec can approximate request-count
|
||||||
on request counts with a custom scenario; no surveyed tool alerts on bytes.
|
anomalies with a custom scenario; nothing handles bytes.
|
||||||
- R5 temporary then permanent: CrowdSec only. BunkerWeb's ban is either one
|
- R5 temporary then permanent: CrowdSec only. BunkerWeb has fixed or permanent,
|
||||||
fixed length or permanent, and does not change with a client's history.
|
not both by history.
|
||||||
- R6 reputation: CrowdSec, BunkerWeb, go-away, caddy-waf (static lists).
|
- R6 reputation: CrowdSec, BunkerWeb, go-away, caddy-waf (static lists).
|
||||||
- R7 AS number lookup: CrowdSec (enrichment), BunkerWeb and caddy-waf (allow or
|
- R7 AS number lookup: CrowdSec (enrichment), BunkerWeb and caddy-waf (allow or
|
||||||
deny), Anubis (hosted service).
|
deny), Anubis (hosted service).
|
||||||
- R8 biased thresholds: none.
|
- R8 biased thresholds: none.
|
||||||
- R9 attack detection: Coraza or ModSecurity with the Core Rule Set (open,
|
- R9 attack detection: Coraza or ModSecurity with the Core Rule Set (open,
|
||||||
standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps Coraza
|
standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps the
|
||||||
or ModSecurity).
|
first).
|
||||||
- R10 env-var sidecar: `owasp/modsecurity-crs`, `coraza-crs-docker`, Anubis.
|
- R10 env-var sidecar: `owasp/modsecurity-crs`, `coraza-crs-docker`, Anubis.
|
||||||
None of these covers anything beyond its one job.
|
None of these covers anything beyond its one job.
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker publish \
|
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
|
||||||
hooks build run example-app
|
run example-app
|
||||||
|
|
||||||
# Makefile targets are thin shims; the implementations live in script/
|
# Makefile targets are thin shims; the implementations live in script/
|
||||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||||
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
|
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
|
||||||
# which test checks. build and run are for working on the code by hand;
|
# which test checks. build and run are for working on the code by hand;
|
||||||
# example-app checks the image with an app built on it; publish builds
|
# example-app checks the image with an app built on it.
|
||||||
# the image on the Docker host and pushes it to the registry.
|
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@script/bootstrap
|
@script/bootstrap
|
||||||
@@ -35,9 +34,6 @@ check:
|
|||||||
docker:
|
docker:
|
||||||
@script/docker
|
@script/docker
|
||||||
|
|
||||||
publish:
|
|
||||||
@script/publish
|
|
||||||
|
|
||||||
hooks:
|
hooks:
|
||||||
@script/install-precommit
|
@script/install-precommit
|
||||||
|
|
||||||
|
|||||||
@@ -7,29 +7,29 @@ requests of scrapers, scanners and other abusers before they reach an app.
|
|||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
A web application firewall reads each request on its way to a web app and
|
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 binary, shipped in
|
||||||
shipped in one image, with two commands that do this together:
|
one image, with two commands that do this together:
|
||||||
|
|
||||||
- `sww rproxy` is a small reverse proxy in front of one app. traefik routes to
|
- `smallwebwaf rproxy` is a small reverse proxy in front of one app. traefik
|
||||||
it. It asks `discriminator` about each request, passes the allowed ones to the
|
routes to it. It asks `servewaf` about each request, passes the allowed ones
|
||||||
app, and passes the app's answers back. It keeps nothing and never touches
|
to the app, and passes the app's answers back. It keeps nothing and never
|
||||||
disk.
|
touches disk.
|
||||||
- `sww discriminator` decides. It runs the checks and keeps the bans, the
|
- `smallwebwaf servewaf` decides. It runs the checks and keeps the bans, the
|
||||||
counters and each client's history. One `discriminator` serves many sites: a
|
counters and each client's history. One `servewaf` serves many sites: a client
|
||||||
client banned through one site is refused on all of them, and its requests on
|
banned through one site is refused on all of them, and its requests on all of
|
||||||
all of them count toward one set of limits.
|
them count toward one set of limits.
|
||||||
|
|
||||||
The site keeps answering while `discriminator` is down, slow or restarting:
|
The site keeps answering while `servewaf` is down, slow or restarting: without
|
||||||
without an answer in time, `rproxy` passes the request to the app.
|
an answer in time, `rproxy` passes the request to the app.
|
||||||
|
|
||||||
It protects against:
|
It protects 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 bytes per minute, per hour and per day. The defaults leave
|
||||||
leave room for a person reading a site for hours. They catch a scraper that
|
room for a person reading a site for hours. They catch a scraper that keeps up
|
||||||
keeps up one request every 2 seconds. A client that breaks a limit is banned:
|
one request every 2 seconds. A client over a limit is refused and banned: for
|
||||||
for a while at first, for three times as long at each repeat, and permanently
|
a while at first, for three times as long at each repeat, and permanently when
|
||||||
when it keeps at it.
|
it keeps at it.
|
||||||
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
|
- Scanners that probe for secrets, such as `/.env` or `/.git/config`. The
|
||||||
default rule file bans such a client at its first probe, and its next request
|
default rule file bans such a client at its first probe, and its next request
|
||||||
during the ban makes the ban permanent. Your own rules, or a list of trap
|
during the ban makes the ban permanent. Your own rules, or a list of trap
|
||||||
@@ -68,9 +68,8 @@ Not built, and not planned:
|
|||||||
|
|
||||||
### The image
|
### The image
|
||||||
|
|
||||||
The image is published at `git.eeqj.de/sneak/smallwebwaf`. You can also build it
|
No image is published. Build it from a clone. `make docker` runs the tests and
|
||||||
from a clone. `make docker` runs the tests and the linter, then builds the
|
the linter, then builds the image, tagged `smallwebwaf`.
|
||||||
image, tagged `smallwebwaf`.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
||||||
@@ -80,11 +79,11 @@ make docker
|
|||||||
|
|
||||||
Push the image to a registry your hosts pull from, and name it there by digest.
|
Push the image to a registry your hosts pull from, and name it there by digest.
|
||||||
|
|
||||||
The image runs `rproxy` by default. To run `discriminator` from the same image,
|
The image runs `rproxy` by default. To run `servewaf` from the same image, set
|
||||||
set the container's `SWW_COMMAND` to `discriminator`. The image's health check
|
the container's `SMALLWEBWAF_COMMAND` to `servewaf`. The image's health check
|
||||||
follows the command: in `rproxy`'s container it passes once `rproxy` answers and
|
follows the command: in `rproxy`'s container it passes once `rproxy` answers and
|
||||||
the app accepts connections, whether or not `discriminator` answers; in
|
the app accepts connections, whether or not `servewaf` answers; in `servewaf`'s
|
||||||
`discriminator`'s container, once `discriminator` answers.
|
container, once `servewaf` answers.
|
||||||
|
|
||||||
### The usual setup: three containers
|
### The usual setup: three containers
|
||||||
|
|
||||||
@@ -92,24 +91,24 @@ The usual setup is three containers on one private network:
|
|||||||
|
|
||||||
- the app;
|
- the app;
|
||||||
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
|
- `rproxy`, in front of the app, which traefik routes to: `SWRP_WAF_BACKEND`
|
||||||
names `discriminator` and `SWRP_UPSTREAM_URL` names the app;
|
names `servewaf` and `SWRP_UPSTREAM_URL` names the app;
|
||||||
- `discriminator`, with `SWW_COMMAND=discriminator` and a volume at
|
- `servewaf`, with `SMALLWEBWAF_COMMAND=servewaf` and a volume at
|
||||||
`/var/lib/sww` for its state files.
|
`/var/lib/smallwebwaf` for its state files.
|
||||||
|
|
||||||
Point traefik's labels at port 8080 of `rproxy`
|
Point traefik's labels at port 8080 of `rproxy`
|
||||||
(`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port
|
(`traefik.http.services.<name>.loadbalancer.server.port=8080`). Publish no port
|
||||||
of the app or of `discriminator`. Anything on that network can reach the app
|
of the app or of `servewaf`. Anything on that network can reach the app around
|
||||||
around `rproxy`, and can ask `discriminator` questions, so the network holds
|
`rproxy`, and can ask `servewaf` questions, so the network holds only what you
|
||||||
only what you trust. Each further app adds its own container and its own
|
trust. Each further app adds its own container and its own `rproxy`, whose
|
||||||
`rproxy`, whose `SWRP_WAF_BACKEND` names the same `discriminator`. The "Smallest
|
`SWRP_WAF_BACKEND` names the same `servewaf`. The "Smallest setup" example below
|
||||||
setup" example below shows this shape.
|
shows this shape.
|
||||||
|
|
||||||
### `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`
|
||||||
runs beside the app in one container. `discriminator` still runs in a container
|
runs beside the app in one container. `servewaf` still runs in a container of
|
||||||
of its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named
|
its own. The app's `Dockerfile` starts `FROM` the `smallwebwaf` image, named by
|
||||||
by digest, and adds:
|
digest, and adds:
|
||||||
|
|
||||||
- the app's binary;
|
- the app's binary;
|
||||||
- the packages the app needs, from the nixpkgs in the image;
|
- the packages the app needs, from the nixpkgs in the image;
|
||||||
@@ -169,16 +168,16 @@ does.
|
|||||||
- 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 traefik gets the app's answer.
|
||||||
`https://<site>/_sww/healthz` answers `ok`. `discriminator`'s log lines about
|
`https://<site>/_smallwebwaf/healthz` answers `ok`. `servewaf`'s log lines
|
||||||
the request have `"action":"forward"`.
|
about the request have `"action":"forward"`.
|
||||||
- To see a refusal without banning yourself, run `discriminator` with
|
- To see a refusal without banning yourself, run `servewaf` with
|
||||||
`SWW_MODE=observe` and ask for `/.env`. The request reaches the app.
|
`SWWAF_MODE=observe` and ask for `/.env`. The request reaches the app.
|
||||||
`discriminator`'s log lines have `"would_action":"temporarily_banned"` and
|
`servewaf`'s log lines have `"would_action":"banned"` and
|
||||||
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
|
`"rule_ids":["env-file"]`. A ban you get in `enforce` mode is lifted as
|
||||||
"Clients and bans" below describes.
|
"Clients and bans" below describes.
|
||||||
- With `SWW_METRICS_TOKEN` set, this answers with the metrics, from a host on
|
- With `SWWAF_METRICS_TOKEN` set, this answers with the metrics, from a host on
|
||||||
`discriminator`'s network:
|
`servewaf`'s network:
|
||||||
`curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/metrics`.
|
`curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/metrics`.
|
||||||
- `make example-app` builds the image and an app on it, runs them, and checks
|
- `make example-app` builds the image and an app on it, runs them, and checks
|
||||||
the whole exchange (see "Entrypoints").
|
the whole exchange (see "Entrypoints").
|
||||||
|
|
||||||
@@ -186,17 +185,16 @@ does.
|
|||||||
|
|
||||||
Every setting is an environment variable, read once at start; changing one means
|
Every setting is an environment variable, read once at start; changing one means
|
||||||
restarting the container. `rproxy` reads only the `SWRP_` settings, and
|
restarting the container. `rproxy` reads only the `SWRP_` settings, and
|
||||||
`discriminator` only the `SWW_` ones. Every setting but `SWRP_WAF_BACKEND` has a
|
`servewaf` only the `SWWAF_` ones. Every setting but `SWRP_WAF_BACKEND` has a
|
||||||
default, chosen for a service on the open internet. A setting that needs an
|
default, chosen for a service on the open internet. A setting that needs an
|
||||||
account, a key, a token or an alert destination stays off until you give one.
|
account, a key, a token or an alert destination stays off until you give one.
|
||||||
|
|
||||||
- Any `SWW_` setting can be given as a file instead, for secrets and long lists:
|
- Any `SWWAF_` setting can be given as a file instead, for secrets and long
|
||||||
`SWW_ADMIN_TOKEN_FILE` names the file that holds `SWW_ADMIN_TOKEN`. The file's
|
lists: `SWWAF_ADMIN_TOKEN_FILE` names the file that holds `SWWAF_ADMIN_TOKEN`.
|
||||||
contents are the value, less one newline at the end. Setting both forms stops
|
The file's contents are the value, less one newline at the end. Setting both
|
||||||
the start. `SWW_LOG_REMOTE_TLS_CA_FILE` and
|
forms stops the start. `SWWAF_LOG_REMOTE_TLS_CA_FILE` already names a file, so
|
||||||
`SWW_CORE_RULE_SET_EXCLUSIONS_FILE` already name a file, so they have no
|
it has no `_FILE` form, and no `SWRP_` setting has one, since `rproxy` never
|
||||||
`_FILE` form. `SWW_COMMAND` cannot be given as a file. No `SWRP_` setting has
|
touches disk.
|
||||||
one, since `rproxy` never touches disk.
|
|
||||||
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
|
- A duration is Go's syntax, with `d` for days: `90s`, `15m`, `24h`, `7d`.
|
||||||
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
|
- A size is a number of bytes with an optional `K`, `M` or `G`, in powers of
|
||||||
1024: `512K`, `100M`, `5G`.
|
1024: `512K`, `100M`, `5G`.
|
||||||
@@ -210,133 +208,127 @@ account, a key, a token or an alert destination stays off until you give one.
|
|||||||
percentage is a whole number from 0 to 100. A path prefix starts with `/`.
|
percentage is a whole number from 0 to 100. A path prefix starts with `/`.
|
||||||
- `off` switches a limit, a threshold or a timeout off where the table says a
|
- `off` switches a limit, a threshold or a timeout off where the table says a
|
||||||
setting takes it.
|
setting takes it.
|
||||||
- The byte limits and the byte anomaly thresholds count only body bytes: the
|
|
||||||
app's response bodies, the visitors' request bodies, or both, as
|
|
||||||
`SWW_BYTES_COUNT` says. Headers are never counted. What the visitor sent over
|
|
||||||
a WebSocket counts as request bytes, and what the app sent as response bytes.
|
|
||||||
- An invalid setting stops the process at start with one log line naming the
|
- An invalid setting stops the process at start with one log line naming the
|
||||||
variable and what is wrong with the value. The settings in effect are logged
|
variable and what is wrong with the value. The settings in effect are logged
|
||||||
on one line at start, with tokens, keys and webhook paths shown as `********`.
|
on one line at start, with tokens, keys and webhook paths shown as `********`.
|
||||||
|
|
||||||
Every setting the code reads, with its default:
|
Every setting the code reads, with its default:
|
||||||
|
|
||||||
| Variable | Default | What it does |
|
| Variable | Default | What it does |
|
||||||
| ---------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
|
| `SMALLWEBWAF_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `servewaf`. |
|
||||||
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
|
| `SWRP_WAF_BACKEND` | required | `servewaf`'s URL, such as `http://servewaf:8080`. |
|
||||||
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
|
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
|
||||||
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
| `SWRP_LISTEN_ADDR` | `:8080` | Where `rproxy` listens; leave the host part empty. |
|
||||||
| `SWRP_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. |
|
| `SWRP_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. |
|
||||||
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `discriminator`'s answer to a question and to a report, in milliseconds. |
|
| `SWRP_TIMEOUT_MS` | `2000` | How long `rproxy` waits for `servewaf`'s answer to a question and to a report, in milliseconds. |
|
||||||
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `discriminator` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
|
| `SWRP_DEFAULT_DISPOSITION` | `allow` | What `rproxy` does with a request when `servewaf` cannot be reached or does not answer in time: `allow` passes it to the app, `deny` answers `503`. |
|
||||||
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
|
| `SWRP_MAX_REQUEST_SIZE` | `100M` | The largest request body, a size or `off`; a larger one is answered `413`. |
|
||||||
| `SWRP_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. |
|
| `SWRP_MAX_RESPONSE_SIZE` | `1G` | The largest response body, a size or `off`; a larger one is answered `502`, or cut off once it has started. |
|
||||||
| `SWRP_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. |
|
| `SWRP_MAX_REQUEST_TIME_MS` | `60000` | How long the visitor may take to send its request line and headers, and then the whole request to reach the app, in milliseconds or `off`. |
|
||||||
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
|
| `SWRP_MAX_RESPONSE_TIME_MS` | `1800000` | How long the answer may take, from the end of the request to its last byte reaching the visitor, in milliseconds or `off`. |
|
||||||
| `SWW_LISTEN_ADDR` | `:8080` | Where `discriminator` listens; leave the host part empty. |
|
| `SWWAF_LISTEN_ADDR` | `:8080` | Where `servewaf` listens; leave the host part empty. |
|
||||||
| `SWW_INSTANCE_NAME` | the container's host name | The name in every line `discriminator` logs, and in every alert and metric, such as `fsn1/discriminator`. |
|
| `SWWAF_INSTANCE_NAME` | the container's host name | The name in every line `servewaf` logs, and in every alert and metric, such as `fsn1/servewaf`. |
|
||||||
| `SWW_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
| `SWWAF_MODE` | `enforce` | `observe` refuses nothing and makes no ban; each log line says what `enforce` would have done. |
|
||||||
| `SWW_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
| `SWWAF_IPV6_GROUP_PREFIX` | `64` | The prefix length of the IPv6 netblock that counts as one client, from 32 to 128. |
|
||||||
| `SWW_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
|
| `SWWAF_MAX_TRACKED_CLIENTS` | `20000` | The most clients kept, in memory and in `clients.json`; the least recently seen is dropped first. |
|
||||||
| `SWW_MAX_BANS` | `5000` | The most bans `discriminator` keeps of its own; bans an admin made do not count. |
|
| `SWWAF_MAX_BANS` | `5000` | The most bans `servewaf` keeps of its own; bans an admin made do not count. |
|
||||||
| `SWW_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
| `SWWAF_ALLOW_NETS` | empty | Netblocks whose clients skip every check, such as your monitoring. |
|
||||||
| `SWW_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients are exempt from rate limiting and the byte limits. |
|
| `SWWAF_RATE_LIMIT_EXEMPT_NETS` | empty | Netblocks whose clients the request and byte limits neither count nor refuse. |
|
||||||
| `SWW_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
| `SWWAF_DENY_NETS` | empty | Netblocks whose clients are refused. |
|
||||||
| `SWW_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; the next request is refused and bans the client. |
|
| `SWWAF_RATE_LIMIT_PER_MINUTE` | `120` | The most requests a client may make in a minute, a count or `off`; one more bans it. |
|
||||||
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. |
|
| `SWWAF_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; one more bans it. |
|
||||||
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. |
|
| `SWWAF_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; one more bans it. |
|
||||||
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes exempt from rate limiting and the byte limits, such as `/assets/`. |
|
| `SWWAF_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
|
||||||
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
| `SWWAF_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes a client's requests may carry in a minute, a size or `off`; the request that takes a client past any byte limit bans it. |
|
||||||
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
| `SWWAF_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes a client's requests may carry in an hour, a size or `off`. |
|
||||||
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`. `SWW_BYTES_COUNT` says which bytes count: response bodies, request bodies or both. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
| `SWWAF_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes a client's requests may carry in a day, a size or `off`. |
|
||||||
| `SWW_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. |
|
| `SWWAF_BYTES_COUNT` | `both` | Which body bytes the byte limits count: `response`, `request` or `both`. |
|
||||||
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. |
|
| `SWWAF_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; one more bans it. |
|
||||||
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
| `SWWAF_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
||||||
| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
| `SWWAF_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
||||||
| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A client that breaks a limit again within this time after its last such ban ended gets a ban three times as long. |
|
| `SWWAF_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. |
|
||||||
| `SWW_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
|
| `SWWAF_MAX_BAN_DURATION` | `7d` | A ban for a broken limit that would be longer than this is permanent instead. |
|
||||||
| `SWW_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
|
| `SWWAF_ATTACK_BAN_DURATION` | `7d` | The ban for a first clear sign of attack: a `ban` rule or a trap path. |
|
||||||
| `SWW_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
|
| `SWWAF_BAN_SCOPE_V4_PREFIX` | `32` | The prefix length of the netblock a ban on an IPv4 client covers, from 0 to 32. |
|
||||||
| `SWW_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs` (a web service), `file` (a local database) or `off`. |
|
| `SWWAF_LOOKUP_SOURCE` | `geojs` | Where each client's AS number and country come from: `geojs`, a web service, `file`, or `off`. |
|
||||||
| `SWW_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
| `SWWAF_LOOKUP_DB_PATH` | empty | The IPinfo Lite database file, in its `.mmdb` form, read while the source is `file`. |
|
||||||
| `SWW_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
|
| `SWWAF_LOOKUP_TIMEOUT` | `800ms` | How long a request waits for its client's first GeoJS answer when a setting needs it, and how long a request to GeoJS may take. |
|
||||||
| `SWW_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
| `SWWAF_ADD_LOOKUP_HEADERS` | `false` | `true` passes the app the client's AS number and country in `X-Client-ASN` and `X-Client-Country`. |
|
||||||
| `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
| `SWWAF_DENIED_COUNTRIES` | empty | Countries whose clients are refused, such as `cn,ru`. |
|
||||||
| `SWW_TAG_LISTS` | empty | Address lists that each add a tag to their clients' requests, as `name=URL` pairs, such as `torexit=https://example.com/tor.txt`. |
|
| `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` | empty | When set, the only countries whose clients get through; a client the lookup cannot place is refused too. |
|
||||||
| `SWW_TAG_DENY` | empty | Tags whose requests are refused, such as `torexit,country=CN`. |
|
| `SWWAF_ASN_LIMIT_PERCENT` | empty | AS numbers whose clients get a percentage of every request and byte limit, such as `AS14061:50,AS16276:25`. |
|
||||||
| `SWW_TAG_LIMIT_PERCENT` | empty | Tags whose clients get a percentage, from 1 to 99, of every request and byte limit, such as `vpn:50,asn=14061:25`; the lowest applies. |
|
| `SWWAF_COUNTRY_LIMIT_PERCENT` | empty | The same by country, such as `cn:25`. |
|
||||||
| `SWW_ROUTEWATCH_URL` | unset | The URL of a routewatch instance, which gives each client's `asn=` and `prefix=` tags. Unset, nothing asks routewatch. |
|
| `SWWAF_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `SWWAF_ASN_LIMIT_PERCENT`. |
|
||||||
| `SWW_ASN_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the AS numbers listed, in place of their `asn=` tag's percentage. |
|
| `SWWAF_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `SWWAF_COUNTRY_LIMIT_PERCENT`. |
|
||||||
| `SWW_COUNTRY_BYTES_PERCENT` | empty | A percentage of the byte limits alone for the countries listed, in place of their `country=` tag's percentage. |
|
| `SWWAF_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
||||||
| `SWW_UNKNOWN_LIMIT_PERCENT` | `100` | The percentage of every limit a client without a country gets. |
|
| `SWWAF_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; where it and `SWWAF_ASN_LIMIT_PERCENT` both give a percentage, the lower applies. |
|
||||||
| `SWW_ASN_LIMIT_PERCENT_URL` | unset | The URL of a text file of `AS<number>:<percent>` lines, fetched like a blocklist; each line counts as an `asn=` tag in `SWW_TAG_LIMIT_PERCENT` (the lowest wins). |
|
| `SWWAF_RULES_DIR` | `/etc/smallwebwaf/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
||||||
| `SWW_RULES_DIR` | `/etc/sww/rules.d` | The directory of the rule files, read at start and again when a file there changes. |
|
| `SWWAF_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
||||||
| `SWW_RULES_ENABLED` | `true` | `false` reads no rule file. |
|
| `SWWAF_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
||||||
| `SWW_TRAP_PATHS` | empty | Paths only scanners ask for, such as `/wp-login.php,/xmlrpc.php`; a request for one bans the client. |
|
| `SWWAF_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
|
||||||
| `SWW_WAF_MODE` | `block` | What the Core Rule Set does with a match: `block` refuses it with `403`, `detect` only logs and alerts it, `off` runs no rule. |
|
| `SWWAF_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
|
||||||
| `SWW_WAF_PARANOIA_LEVEL` | `1` | The Core Rule Set's paranoia level, from 1 to 4; each level up finds more attacks and takes more ordinary requests for attacks. |
|
| `SWWAF_WAF_ANOMALY_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_ANOMALY_THRESHOLD` | `5` | The Core Rule Set score at which a request is a match, a whole number or `off`; one critical rule scores 5. |
|
| `SWWAF_WAF_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_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. |
|
| `SWWAF_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
||||||
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
| `SWWAF_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
||||||
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
| `SWWAF_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_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. |
|
| `SWWAF_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
||||||
| `SWW_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`. |
|
| `SWWAF_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
||||||
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
| `SWWAF_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
||||||
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
| `SWWAF_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
|
||||||
| `SWW_DNSBL_ZONES` | empty | DNS blocklists asked about each new client in the background, such as `dnsbl.dronebl.org`. |
|
| `SWWAF_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
|
||||||
| `SWW_DNSBL_RESOLVER` | unset | The resolver the zones are asked through, an IP address with an optional port; unset, the host's resolver is used. |
|
| `SWWAF_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
||||||
| `SWW_ABUSEIPDB_KEY` | unset | The key of your AbuseIPDB account; set, a client that has committed an offence is checked in the background. |
|
| `SWWAF_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
||||||
| `SWW_ABUSEIPDB_MIN_SCORE` | `75` | The least AbuseIPDB abuse confidence score, from 0 to 100, that is a hit. |
|
| `SWWAF_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
|
||||||
| `SWW_ABUSEIPDB_DAILY_BUDGET` | `900` | The most AbuseIPDB checks made in a day. |
|
| `SWWAF_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
|
||||||
| `SWW_CROWDSEC_LAPI_URL` | unset | The local API of a CrowdSec engine you run, such as `http://172.17.0.1:8080`; its decision list is fetched every minute, and a listed client is banned. |
|
| `SWWAF_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
|
||||||
| `SWW_CROWDSEC_LAPI_KEY` | unset | The key the engine gave for `smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it; set with the URL, never alone. |
|
| `SWWAF_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
||||||
| `SWW_REPUTATION_ACTION` | `limit:25` | What a client listed by a DNS blocklist, or scored a hit by AbuseIPDB, gets: `deny`, `limit:<percent>` or `log`. |
|
| `SWWAF_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
||||||
| `SWW_REPUTATION_CACHE_TTL` | `24h` | How long a DNS blocklist verdict or an AbuseIPDB score is used. |
|
| `SWWAF_STATE_DIR` | `/var/lib/smallwebwaf` | The directory of the state files, an absolute path. |
|
||||||
| `SWW_REPUTATION_TIMEOUT` | `2s` | How long a DNS blocklist query or an AbuseIPDB check may take. |
|
| `SWWAF_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
||||||
| `SWW_STATE_DIR` | `/var/lib/sww` | The directory of the state files, an absolute path. |
|
| `SWWAF_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
|
||||||
| `SWW_STATE_WRITE_DELAY` | `10s` | How long `bans.json` waits to be written after a ban is made, lifted or made permanent. |
|
| `SWWAF_LOG_LEVEL` | `info` | The least severe of `servewaf`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
|
||||||
| `SWW_STATE_COUNTER_INTERVAL` | `15m` | How often every state file is written. |
|
| `SWWAF_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
|
||||||
| `SWW_LOG_LEVEL` | `info` | The least severe of `discriminator`'s own messages that are written: `debug`, `info`, `warn` or `error`; request lines are always written. |
|
| `SWWAF_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `servewaf` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
|
||||||
| `SWW_LOG_REQUEST_HEADERS` | `accept,accept-language,accept-encoding,content-type,origin,range` | The request headers whose values the request log records; `Authorization` and `Cookie` are never recorded. |
|
| `SWWAF_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
|
||||||
| `SWW_LOG_REMOTE_URL` | unset | A syslog server that also gets every line `discriminator` writes: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://` with a host and a port. |
|
| `SWWAF_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
||||||
| `SWW_LOG_REMOTE_TLS_CA_FILE` | unset | A file of PEM certificates a `syslog+tls` server's certificate must chain to, instead of the host's own. |
|
| `SWWAF_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
||||||
| `SWW_LOG_REMOTE_BUFFER` | `10000` | The most lines held while they wait to be sent; past it, the oldest is dropped. |
|
| `SWWAF_LOG_REMOTE_APP_NAME` | `SWWAF_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
|
||||||
| `SWW_LOG_REMOTE_FACILITY` | `local0` | The syslog facility of each record sent. |
|
| `SWWAF_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
||||||
| `SWW_LOG_REMOTE_APP_NAME` | `SWW_INSTANCE_NAME` | The app name in each syslog record, 1 to 48 printable ASCII characters without a space. |
|
| `SWWAF_METRICS_TOKEN` | unset | The bearer token for `/_smallwebwaf/metrics`, at least 32 characters; unset, the metrics are off. |
|
||||||
| `SWW_ADMIN_TOKEN` | unset | The bearer token for the ban and client endpoints, at least 32 characters; unset, they answer `404`. |
|
| `SWWAF_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
|
||||||
| `SWW_METRICS_TOKEN` | unset | The bearer token for `/_sww/metrics`, at least 32 characters; unset, the metrics are off. |
|
| `SWWAF_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
||||||
| `SWW_METRICS_TOP_N` | `50` | How many AS numbers and how many countries get metric series of their own; the rest are summed as `other`. |
|
| `SWWAF_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
||||||
| `SWW_ALERT_WEBHOOK_URL` | unset | A webhook each alert is posted to as a JSON object. |
|
| `SWWAF_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
|
||||||
| `SWW_ALERT_WEBHOOK_HEADERS` | empty | Headers sent with each post, a list of `Name:value`, such as `Authorization:Bearer <token>`. |
|
| `SWWAF_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
|
||||||
| `SWW_ALERT_SLACK_WEBHOOK_URL` | unset | A Slack incoming webhook each alert is posted to as a message. |
|
| `SWWAF_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
|
||||||
| `SWW_ALERT_NTFY_URL` | unset | An ntfy topic each alert is published to, as its full URL, such as `https://ntfy.sh/my-alerts`. |
|
| `SWWAF_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
|
||||||
| `SWW_ALERT_NTFY_TOKEN` | unset | An ntfy access token sent with each alert. |
|
| `SWWAF_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
|
||||||
| `SWW_ALERT_EVENTS` | `ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error` | The events alerts are sent for; the hourly `summary` is sent whatever it says. |
|
| `SWWAF_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
|
||||||
| `SWW_ALERT_COOLDOWN` | `15m` | How long after an alert its repeats are held back and counted, a duration or `off`. |
|
| `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
||||||
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
|
| `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
|
||||||
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
| `SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when one client's requests carry more bytes than this in a minute, a size. |
|
||||||
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
|
| `SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's requests carry more bytes than this in an hour, a size. |
|
||||||
| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in a minute, a size. |
|
| `SWWAF_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
|
||||||
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in an hour, a size. |
|
| `SWWAF_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
|
||||||
| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
|
| `SWWAF_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when the netblock around a client carries more bytes than this in a minute, a size. |
|
||||||
| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
|
| `SWWAF_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the netblock around a client carries more bytes than this in an hour, a size. |
|
||||||
| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. |
|
| `SWWAF_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWWAF_ANOMALY_NET_` thresholds count, from 0 to 32. |
|
||||||
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. |
|
| `SWWAF_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWWAF_ANOMALY_NET_` thresholds count, from 0 to 128. |
|
||||||
| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
|
| `SWWAF_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
||||||
| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
|
| `SWWAF_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
|
||||||
| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
| `SWWAF_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when one AS number carries more bytes than this in a minute, a size. |
|
||||||
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
|
| `SWWAF_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when one AS number carries more bytes than this in an hour, a size. |
|
||||||
| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. |
|
| `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
||||||
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. |
|
| `SWWAF_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
|
||||||
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
| `SWWAF_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when the whole service carries more bytes than this in a minute, a size. |
|
||||||
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
|
| `SWWAF_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the whole service carries more bytes than this in an hour, a size. |
|
||||||
| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. |
|
| `SWWAF_WATCH_NETS` | empty | Named netblocks the `SWWAF_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
|
||||||
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. |
|
| `SWWAF_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
||||||
| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
|
| `SWWAF_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
||||||
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
| `SWWAF_WATCH_BYTES_PER_MINUTE` | `off` | Alert when a watched netblock carries more bytes than this in a minute, a size. |
|
||||||
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
| `SWWAF_WATCH_BYTES_PER_HOUR` | `off` | Alert when a watched netblock carries more bytes than this in an hour, a size. |
|
||||||
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. |
|
|
||||||
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. |
|
|
||||||
|
|
||||||
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
|
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
|
||||||
full, and the limits that are fixed rather than settings.
|
full, and the limits that are fixed rather than settings.
|
||||||
@@ -351,8 +343,8 @@ own, where each reaches the others by service name. Each example publishes
|
|||||||
|
|
||||||
### Smallest setup
|
### Smallest setup
|
||||||
|
|
||||||
One app, `rproxy` in front of it, and `discriminator` with a volume for its
|
One app, `rproxy` in front of it, and `servewaf` with a volume for its state
|
||||||
state files, every setting at its default.
|
files, every setting at its default.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -363,20 +355,20 @@ services:
|
|||||||
rproxy:
|
rproxy:
|
||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SWRP_WAF_BACKEND: http://discriminator:8080
|
SWRP_WAF_BACKEND: http://servewaf:8080
|
||||||
SWRP_UPSTREAM_URL: http://app:8081
|
SWRP_UPSTREAM_URL: http://app:8081
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
discriminator:
|
servewaf:
|
||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SWW_COMMAND: discriminator
|
SMALLWEBWAF_COMMAND: servewaf
|
||||||
volumes:
|
volumes:
|
||||||
- discriminator-state:/var/lib/sww
|
- servewaf-state:/var/lib/smallwebwaf
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
discriminator-state:
|
servewaf-state:
|
||||||
```
|
```
|
||||||
|
|
||||||
### Custom per-address limits
|
### Custom per-address limits
|
||||||
@@ -399,45 +391,46 @@ services:
|
|||||||
rproxy:
|
rproxy:
|
||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SWRP_WAF_BACKEND: http://discriminator:8080
|
SWRP_WAF_BACKEND: http://servewaf:8080
|
||||||
SWRP_UPSTREAM_URL: http://app:8081
|
SWRP_UPSTREAM_URL: http://app:8081
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
discriminator:
|
servewaf:
|
||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SWW_COMMAND: discriminator
|
SMALLWEBWAF_COMMAND: servewaf
|
||||||
# Monitoring skips every check.
|
# Monitoring skips every check.
|
||||||
SWW_ALLOW_NETS: 192.0.2.10
|
SWWAF_ALLOW_NETS: 192.0.2.10
|
||||||
# The office and the automation host are never rate limited.
|
# The office and the automation host are never rate limited.
|
||||||
SWW_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
|
SWWAF_RATE_LIMIT_EXEMPT_NETS: 203.0.113.0/24,198.51.100.7
|
||||||
# A netblock that sends nothing wanted.
|
# A netblock that sends nothing wanted.
|
||||||
SWW_DENY_NETS: 198.51.100.128/25
|
SWWAF_DENY_NETS: 198.51.100.128/25
|
||||||
# Tighter limits for everyone else: a small static site.
|
# Tighter limits for everyone else: a small static site.
|
||||||
SWW_RATE_LIMIT_PER_MINUTE: "60"
|
SWWAF_RATE_LIMIT_PER_MINUTE: "60"
|
||||||
SWW_RATE_LIMIT_PER_HOUR: "600"
|
SWWAF_RATE_LIMIT_PER_HOUR: "600"
|
||||||
SWW_RATE_LIMIT_PER_DAY: "3000"
|
SWWAF_RATE_LIMIT_PER_DAY: "3000"
|
||||||
# Hosting networks and one country get a share of those limits.
|
# Hosting networks and one country get a share of those limits.
|
||||||
SWW_TAG_LIMIT_PERCENT: asn=14061:50,asn=16276:25,country=CN:25
|
SWWAF_ASN_LIMIT_PERCENT: AS14061:50,AS16276:25
|
||||||
|
SWWAF_COUNTRY_LIMIT_PERCENT: cn:25
|
||||||
# Paths only scanners ask for; one request bans the client.
|
# Paths only scanners ask for; one request bans the client.
|
||||||
SWW_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
SWWAF_TRAP_PATHS: /wp-login.php,/xmlrpc.php
|
||||||
# Answer a refused client with 429 rather than 403.
|
# Answer a refused client with 429 rather than 403.
|
||||||
SWW_BAN_RESPONSE: "429"
|
SWWAF_BAN_RESPONSE: "429"
|
||||||
SWW_LIMIT_BAN_DURATION: 30m
|
SWWAF_LIMIT_BAN_DURATION: 30m
|
||||||
volumes:
|
volumes:
|
||||||
- discriminator-state:/var/lib/sww
|
- servewaf-state:/var/lib/smallwebwaf
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
discriminator-state:
|
servewaf-state:
|
||||||
```
|
```
|
||||||
|
|
||||||
### `rproxy` beside the app
|
### `rproxy` beside the app
|
||||||
|
|
||||||
The app is built on the `smallwebwaf` image, with `rproxy` inside its container.
|
The app is built on the `smallwebwaf` image, with `rproxy` inside its container.
|
||||||
`app/` holds the `Dockerfile` and `app.run` from "`rproxy` in the app's
|
`app/` holds the `Dockerfile` and `app.run` from "`rproxy` in the app's
|
||||||
container". `rproxy` answers `503` to every request while `discriminator` is
|
container". `rproxy` answers `503` to every request while `servewaf` is down,
|
||||||
down, and `discriminator`:
|
and `servewaf`:
|
||||||
|
|
||||||
- observes before it enforces;
|
- observes before it enforces;
|
||||||
- looks addresses up in a local database rather than GeoJS;
|
- looks addresses up in a local database rather than GeoJS;
|
||||||
@@ -450,46 +443,46 @@ services:
|
|||||||
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
|
# A Dockerfile built FROM the smallwebwaf image, with rproxy inside.
|
||||||
build: ./app
|
build: ./app
|
||||||
environment:
|
environment:
|
||||||
SWRP_WAF_BACKEND: http://discriminator:8080
|
SWRP_WAF_BACKEND: http://servewaf:8080
|
||||||
# Answer 503 rather than pass a request on while discriminator is down.
|
# Answer 503 rather than pass a request on while servewaf is down.
|
||||||
SWRP_DEFAULT_DISPOSITION: deny
|
SWRP_DEFAULT_DISPOSITION: deny
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|
||||||
discriminator:
|
servewaf:
|
||||||
image: smallwebwaf
|
image: smallwebwaf
|
||||||
environment:
|
environment:
|
||||||
SWW_COMMAND: discriminator
|
SMALLWEBWAF_COMMAND: servewaf
|
||||||
SWW_INSTANCE_NAME: fsn1/discriminator
|
SWWAF_INSTANCE_NAME: fsn1/servewaf
|
||||||
# Refuse nothing yet; log what enforce mode would have done.
|
# Refuse nothing yet; log what enforce mode would have done.
|
||||||
SWW_MODE: observe
|
SWWAF_MODE: observe
|
||||||
# Look addresses up in a local IPinfo Lite database, never GeoJS.
|
# Look addresses up in a local IPinfo Lite database, never GeoJS.
|
||||||
SWW_LOOKUP_SOURCE: file
|
SWWAF_LOOKUP_SOURCE: file
|
||||||
SWW_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
|
SWWAF_LOOKUP_DB_PATH: /var/lib/ipinfo/ipinfo_lite.mmdb
|
||||||
# Alerts to an ntfy topic, with its token read from a file.
|
# Alerts to an ntfy topic, with its token read from a file.
|
||||||
SWW_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
|
SWWAF_ALERT_NTFY_URL: https://ntfy.example/smallwebwaf
|
||||||
SWW_ALERT_NTFY_TOKEN_FILE: /etc/sww/tokens/ntfy
|
SWWAF_ALERT_NTFY_TOKEN_FILE: /etc/smallwebwaf/tokens/ntfy
|
||||||
SWW_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error
|
SWWAF_ALERT_EVENTS: ban,permanent_ban,waf_block,source_failure,file_error
|
||||||
volumes:
|
volumes:
|
||||||
- discriminator-state:/var/lib/sww
|
- servewaf-state:/var/lib/smallwebwaf
|
||||||
- /srv/ipinfo:/var/lib/ipinfo:ro
|
- /srv/ipinfo:/var/lib/ipinfo:ro
|
||||||
- /srv/sww/tokens:/etc/sww/tokens:ro
|
- /srv/smallwebwaf/tokens:/etc/smallwebwaf/tokens:ro
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
discriminator-state:
|
servewaf-state:
|
||||||
```
|
```
|
||||||
|
|
||||||
The database is mounted by its directory, not by file, so that a replaced
|
The database is mounted by its directory, not by file, so that a replaced
|
||||||
database is seen. A token file belongs to uid 65532, the `sww` user, with mode
|
database is seen. A token file belongs to uid 65532, the `smallwebwaf` user,
|
||||||
`0400`. This one holds an access token the ntfy server issued, which
|
with mode `0400`. This one holds an access token the ntfy server issued, which
|
||||||
`ntfy token add <user>` prints there:
|
`ntfy token add <user>` prints there:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
mkdir -p /srv/sww/tokens
|
mkdir -p /srv/smallwebwaf/tokens
|
||||||
# <token> is the access token that ntfy printed.
|
# <token> is the access token that ntfy printed.
|
||||||
echo '<token>' > /srv/sww/tokens/ntfy
|
echo '<token>' > /srv/smallwebwaf/tokens/ntfy
|
||||||
chown 65532:65532 /srv/sww/tokens/ntfy
|
chown 65532:65532 /srv/smallwebwaf/tokens/ntfy
|
||||||
chmod 0400 /srv/sww/tokens/ntfy
|
chmod 0400 /srv/smallwebwaf/tokens/ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
@@ -498,58 +491,54 @@ How it works inside. [`SPEC.md`](SPEC.md) gives every rule in full, and why.
|
|||||||
|
|
||||||
### What happens to a request
|
### What happens to a request
|
||||||
|
|
||||||
A request goes through these steps, in this order. Once `discriminator` refuses
|
A request goes through these steps, in this order. Once `servewaf` refuses a
|
||||||
a request, the steps after that are skipped, apart from the report, and nothing
|
request, the steps after that are skipped, apart from the report, and nothing of
|
||||||
of the request reaches the app.
|
the request reaches the app.
|
||||||
|
|
||||||
`rproxy` takes the first two:
|
`rproxy` takes the first two:
|
||||||
|
|
||||||
1. The health check, `GET /_sww/healthz`, is answered at once.
|
1. The health check, `GET /_smallwebwaf/healthz`, is answered at once.
|
||||||
2. The visitor's address is worked out from `X-Forwarded-For`, believed only
|
2. The visitor's address is worked out from `X-Forwarded-For`, believed only
|
||||||
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `discriminator` the request as
|
from `SWRP_TRUSTED_PROXIES`. `rproxy` sends `servewaf` the request as the
|
||||||
the visitor sent it, with the first 64 KiB of its body.
|
visitor sent it, with the first 64 KiB of its body.
|
||||||
|
|
||||||
`discriminator` takes the checks:
|
`servewaf` takes the checks:
|
||||||
|
|
||||||
3. The tag lists.
|
3. The static lists, `SWWAF_ALLOW_NETS` and `SWWAF_DENY_NETS`.
|
||||||
4. The static lists, `SWW_ALLOW_NETS` and `SWW_DENY_NETS`.
|
4. The bans.
|
||||||
5. The bans.
|
5. The lookup of the client's AS number and country.
|
||||||
6. The lookup of the client's AS number and country.
|
6. The country lists.
|
||||||
7. The country list.
|
7. The reputation sources.
|
||||||
8. The reputation sources.
|
8. The client's limit percentage, from its AS number, its country or a
|
||||||
9. The tag refusals of `SWW_TAG_DENY`.
|
reputation hit.
|
||||||
10. The client's limit percentage, from its tags, its AS number, its country or
|
9. The rate limits.
|
||||||
a reputation hit.
|
10. The trap paths.
|
||||||
11. The rate limits.
|
11. The rule files.
|
||||||
12. The trap paths.
|
12. The header limits, and then the Core Rule Set.
|
||||||
13. The rule files.
|
13. A path under `/_smallwebwaf/` is answered `404`, so that the app never sees
|
||||||
14. The header limits, and then the Core Rule Set.
|
that prefix.
|
||||||
15. The tag refusals again, so that `exploitsignature` can refuse too.
|
|
||||||
16. A path under `/_sww/` is answered `404`, so that the app never sees that
|
|
||||||
prefix.
|
|
||||||
|
|
||||||
`rproxy` takes the rest, with `discriminator` counting at the end:
|
`rproxy` takes the rest, with `servewaf` counting at the end:
|
||||||
|
|
||||||
17. The request is passed to the app, streaming.
|
14. The request is passed to the app, streaming.
|
||||||
18. After the answer, `rproxy` sends `discriminator` a report with the status
|
15. After the answer, `rproxy` sends `servewaf` a report with the status and the
|
||||||
and the body bytes each way. For an allowed request, `discriminator` counts
|
bytes each way. For an allowed request, `servewaf` counts the bytes toward
|
||||||
the bytes `SWW_BYTES_COUNT` names toward the byte limits, bans the client if
|
the byte limits and adds the request to the client's history.
|
||||||
they take it past one, and adds the request to the client's history.
|
|
||||||
|
|
||||||
A refused visitor is answered with `SWW_BAN_RESPONSE`. A `block` rule or a Core
|
A refused visitor is answered with `SWWAF_BAN_RESPONSE`. A `block` rule or a
|
||||||
Rule Set match answers `403` and bans nobody, since the Core Rule Set has false
|
Core Rule Set match answers `403` and bans nobody, since the Core Rule Set has
|
||||||
positives; such refusals count toward `SWW_ERROR_BURST_THRESHOLD` instead. A
|
false positives; such refusals count toward `SWWAF_ERROR_BURST_THRESHOLD`
|
||||||
request over the header limits of step 14 is answered `431`, and bans nobody
|
instead. A request over the header limits of step 12 is answered `431`, and bans
|
||||||
either. `rproxy` itself answers `405` to a method it does not pass on, and the
|
nobody either. `rproxy` itself answers `405` to a method it does not pass on,
|
||||||
status for a size or time limit, such as `413` for a body too large.
|
and the status for a size or time limit, such as `413` for a body too large.
|
||||||
|
|
||||||
### Clients and bans
|
### Clients and bans
|
||||||
|
|
||||||
- A client is one IPv4 address or one IPv6 netblock of `SWW_IPV6_GROUP_PREFIX`,
|
- A client is one IPv4 address or one IPv6 netblock of
|
||||||
since one abuser usually controls a whole /64.
|
`SWWAF_IPV6_GROUP_PREFIX`, since one abuser usually controls a whole /64.
|
||||||
- Each limit is counted over a sliding window with a few integers per client.
|
- Each limit is counted over a sliding window with a few integers per client.
|
||||||
The table of clients and the table of bans each have a bound
|
The table of clients and the table of bans each have a bound
|
||||||
(`SWW_MAX_TRACKED_CLIENTS`, `SWW_MAX_BANS`), so memory is bounded too.
|
(`SWWAF_MAX_TRACKED_CLIENTS`, `SWWAF_MAX_BANS`), so memory is bounded too.
|
||||||
- A ban refuses every request from its netblock until it ends. Requests refused
|
- A ban refuses every request from its netblock until it ends. Requests refused
|
||||||
under a ban count toward no limit. A permanent ban ends only when an admin
|
under a ban count toward no limit. A permanent ban ends only when an admin
|
||||||
lifts it.
|
lifts it.
|
||||||
@@ -558,53 +547,28 @@ status for a size or time limit, such as `413` for a body too large.
|
|||||||
bans. An admin adds, keeps or lifts a ban by editing `bans.json` or through
|
bans. An admin adds, keeps or lifts a ban by editing `bans.json` or through
|
||||||
the ban endpoints (see [`docs/admin-endpoints.md`](docs/admin-endpoints.md)).
|
the ban endpoints (see [`docs/admin-endpoints.md`](docs/admin-endpoints.md)).
|
||||||
|
|
||||||
### Request tags
|
|
||||||
|
|
||||||
Each request gets tags from what `discriminator` knows of its client, written in
|
|
||||||
the `tags` field of its log line:
|
|
||||||
|
|
||||||
- `asn=<number>` and `country=<code>`, from the lookup;
|
|
||||||
- one tag per list of `SWW_TAG_LISTS` that holds the client, such as `torexit`
|
|
||||||
or `vpn=mullvad`;
|
|
||||||
- `crowdsec`, `dnsbl=<zone>` and `abuseipdb`, when that reputation source lists
|
|
||||||
the client;
|
|
||||||
- `exploitsignature`, when a rule file or the Core Rule Set matched the request;
|
|
||||||
- `asn=<number>` and `prefix=<netblock>`, from a routewatch instance, while
|
|
||||||
`SWW_ROUTEWATCH_URL` is set.
|
|
||||||
|
|
||||||
`SWW_TAG_DENY` refuses requests by tag, and `SWW_TAG_LIMIT_PERCENT` lowers a
|
|
||||||
client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
|
||||||
|
|
||||||
### Attack detection
|
### Attack detection
|
||||||
|
|
||||||
- The Core Rule Set reads the method, the URL with its query string, and the
|
- The Core Rule Set reads the method, the URL with its query string, and the
|
||||||
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
|
headers of every request, and no body unless `SWWAF_WAF_BODY_LIMIT` is set. In
|
||||||
front of a code forge, bodies carry what people write and publish, which the
|
front of a code forge, bodies carry what people write and publish, which the
|
||||||
rules take for attacks.
|
rules take for attacks.
|
||||||
- `smallwebwaf` changes the Core Rule Set in nine ways so that it lets ordinary
|
- `smallwebwaf` changes the Core Rule Set in six ways so that it lets ordinary
|
||||||
requests to a code forge or to WordPress through, such as git over HTTP and
|
requests to a code forge through, such as git over HTTP and sign-in through
|
||||||
sign-in through Git Credential Manager. On gitea's own paths, its searches,
|
Git Credential Manager. No setting undoes them; "Attack detection" in
|
||||||
file names and branch names get through too. On WordPress's own paths, its
|
[`SPEC.md`](SPEC.md) lists them.
|
||||||
searches and file names do. No setting undoes the changes; "Attack detection"
|
|
||||||
in [`SPEC.md`](SPEC.md) lists them.
|
|
||||||
- These apps work out of the box at the default settings, and the Core Rule Set
|
|
||||||
refuses none of their ordinary requests:
|
|
||||||
- gitea;
|
|
||||||
- WordPress.
|
|
||||||
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
|
|
||||||
fields or paths.
|
|
||||||
- Rule files are plain text, one rule to a line, edited by hand and read again
|
- Rule files are plain text, one rule to a line, edited by hand and read again
|
||||||
while `discriminator` runs. The image ships one file of probes no real visitor
|
while `servewaf` runs. The image ships one file of probes no real visitor
|
||||||
sends (see [`docs/rules.md`](docs/rules.md)).
|
sends (see [`docs/rules.md`](docs/rules.md)).
|
||||||
|
|
||||||
### State files
|
### State files
|
||||||
|
|
||||||
`discriminator` keeps everything in memory, and the five JSON files in
|
`servewaf` keeps everything in memory, and the five JSON files in
|
||||||
`SWW_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
|
`SWWAF_STATE_DIR` hold a copy: `bans.json`, `clients.json`, `lookups.json`,
|
||||||
`reputation.json` and `alerts.json`. Each is written whole, to a temporary file
|
`reputation.json` and `alerts.json`. Each is written whole, to a temporary file
|
||||||
renamed over the real one, so a crash leaves a complete file. `discriminator`
|
renamed over the real one, so a crash leaves a complete file. `servewaf` notices
|
||||||
notices a file edited on disk and takes the edit in; an edit that does not parse
|
a file edited on disk and takes the edit in; an edit that does not parse is set
|
||||||
is set aside as `<name>.bad`, and never stops the running process (see
|
aside as `<name>.bad`, and never stops the running process (see
|
||||||
[`docs/state-files.md`](docs/state-files.md)).
|
[`docs/state-files.md`](docs/state-files.md)).
|
||||||
|
|
||||||
### The image and its processes
|
### The image and its processes
|
||||||
@@ -613,33 +577,33 @@ is set aside as `<name>.bad`, and never stops the running process (see
|
|||||||
come from Ubuntu's archive, and nixpkgs is fixed at one release. An app's
|
come from Ubuntu's archive, and nixpkgs is fixed at one release. An app's
|
||||||
`Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the
|
`Dockerfile` installs a package with `nix-env -iA nixpkgs.<name>` and gets the
|
||||||
same package each time it is built (see [`docs/image.md`](docs/image.md)).
|
same package each time it is built (see [`docs/image.md`](docs/image.md)).
|
||||||
- runit, a service supervisor, runs `sww` as the `sww` user (uid and gid 65532)
|
- runit, a service supervisor, runs `smallwebwaf` as the `smallwebwaf` user (uid
|
||||||
and starts it again when it exits. The `run` script of `discriminator` gives
|
and gid 65532) and starts it again when it exits. The `run` script of
|
||||||
the state directory to the `sww` user at each start, so a volume needs no
|
`servewaf` gives the state directory to the `smallwebwaf` user at each start,
|
||||||
change of owner.
|
so a volume needs no change of owner.
|
||||||
- `rproxy` and `discriminator` use no token between them. `discriminator`'s own
|
- `rproxy` and `servewaf` use no token between them. `servewaf`'s own endpoints,
|
||||||
endpoints, under `/_sww/`, are for your admin tools and your metrics
|
under `/_smallwebwaf/`, are for your admin tools and your metrics collector,
|
||||||
collector, at its own address on the private network; nothing routes the
|
at its own address on the private network; nothing routes the internet there.
|
||||||
internet there.
|
|
||||||
- `docker stop` gives requests in progress five seconds to finish. With `rproxy`
|
- `docker stop` gives requests in progress five seconds to finish. With `rproxy`
|
||||||
in the app's container, the app must keep answering for five seconds after
|
in the app's container, the app must keep answering for five seconds after
|
||||||
`SIGTERM` for that to help them.
|
`SIGTERM` for that to help them.
|
||||||
- Only faults found at start stop a process: an invalid setting, a rule file or
|
- Only faults found at start stop a process: an invalid setting, a rule file or
|
||||||
state file that does not parse, a lookup database that cannot be read, or an
|
state file that does not parse, a lookup database that cannot be read, or an
|
||||||
address it cannot listen on. Once running, a failing lookup service,
|
address it cannot listen on. Once running, a failing lookup service,
|
||||||
reputation source or alert destination never takes `discriminator` down.
|
reputation source or alert destination never takes `servewaf` down.
|
||||||
|
|
||||||
### Packages
|
### Packages
|
||||||
|
|
||||||
- `cmd/sww`: the binary, which only calls `internal/sww`.
|
- `cmd/smallwebwaf`: the binary, which only calls `internal/smallwebwaf`.
|
||||||
- `internal/sww`: the process, as the command it is given, and the health check.
|
- `internal/smallwebwaf`: the process, as the command it is given, and the
|
||||||
|
health check.
|
||||||
- `internal/config`: reads the settings, the one place they are read.
|
- `internal/config`: reads the settings, the one place they are read.
|
||||||
- `internal/rproxy`: the reverse proxy in front of one app.
|
- `internal/rproxy`: the reverse proxy in front of one app.
|
||||||
- `internal/discriminator`: `discriminator`'s server, which runs the checks and
|
- `internal/servewaf`: `servewaf`'s server, which runs the checks and answers
|
||||||
answers its own endpoints.
|
its own endpoints.
|
||||||
- `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`,
|
- `internal/ratelimit`, `internal/bans`, `internal/rules`, `internal/waf`,
|
||||||
`internal/lookup`, `internal/reputation`, `internal/routewatch`,
|
`internal/lookup`, `internal/reputation`, `internal/anomaly` and
|
||||||
`internal/anomaly` and `internal/alerts`: one check or source each.
|
`internal/alerts`: one check or source each.
|
||||||
- `internal/state`, `internal/requestlog`, `internal/remotelog` and
|
- `internal/state`, `internal/requestlog`, `internal/remotelog` and
|
||||||
`internal/metrics`: the state files, the log lines, the syslog sender and the
|
`internal/metrics`: the state files, the log lines, the syslog sender and the
|
||||||
metrics.
|
metrics.
|
||||||
@@ -696,39 +660,31 @@ on the host.
|
|||||||
`make check`.
|
`make check`.
|
||||||
- `script/docker`: builds the image, whose build runs the tests and the linter
|
- `script/docker`: builds the image, whose build runs the tests and the linter
|
||||||
first. `make docker`.
|
first. `make docker`.
|
||||||
- `script/publish`: runs `script/docker`, whose build happens on the Docker host
|
|
||||||
named by `DOCKER_HOST` (`ssh://root@fsn1app1` unless set). It then pushes the
|
|
||||||
image to `PUBLISH_IMAGE` (`git.eeqj.de/sneak/smallwebwaf` unless set). It
|
|
||||||
refuses a working tree with uncommitted changes. `make publish`. It pushes
|
|
||||||
four tags:
|
|
||||||
- the UTC date as `YYYY-MM-DD`;
|
|
||||||
- the short commit hash;
|
|
||||||
- the branch name;
|
|
||||||
- `latest`.
|
|
||||||
- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the
|
- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the
|
||||||
image build.
|
image build.
|
||||||
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
|
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
|
||||||
- `script/install-precommit`: installs that hook. `make hooks`.
|
- `script/install-precommit`: installs that hook. `make hooks`.
|
||||||
- `script/build`: builds `bin/sww` with the Go on your host. `make build`.
|
- `script/build`: builds `bin/smallwebwaf` with the Go on your host.
|
||||||
- `script/run`: builds `bin/sww` and runs `discriminator` and `rproxy` on the
|
`make build`.
|
||||||
|
- `script/run`: builds `bin/smallwebwaf` and runs `servewaf` and `rproxy` on the
|
||||||
host, with the settings in your environment. `make run`. Unless your settings
|
host, with the settings in your environment. `make run`. Unless your settings
|
||||||
say otherwise:
|
say otherwise:
|
||||||
- `rproxy` listens on port 8080, in front of an app at
|
- `rproxy` listens on port 8080, in front of an app at
|
||||||
`http://127.0.0.1:8081`;
|
`http://127.0.0.1:8081`;
|
||||||
- `discriminator` listens on port 8090;
|
- `servewaf` listens on port 8090;
|
||||||
- its state files go in `bin/state`;
|
- its state files go in `bin/state`;
|
||||||
- its rule files are those of `share/rules.d`.
|
- its rule files are those of `share/rules.d`.
|
||||||
- `script/example-app`: builds the image and the example app
|
- `script/example-app`: builds the image and the example app
|
||||||
(`deploy/example-app`) on it, runs the app with `rproxy` in its container and
|
(`deploy/example-app`) on it, runs the app with `rproxy` in its container and
|
||||||
`discriminator` in another, and checks them. Then it removes the containers,
|
`servewaf` in another, and checks them. Then it removes the containers, the
|
||||||
the network, the volumes and the images. Needs network access.
|
network, the volumes and the images. Needs network access. `make example-app`.
|
||||||
`make example-app`. It checks:
|
It checks:
|
||||||
- the health checks;
|
- the health checks;
|
||||||
- a request passed to the app;
|
- a request passed to the app;
|
||||||
- a ban for a broken limit, and a ban for a probe made permanent;
|
- a ban for a broken limit, and a ban for a probe made permanent;
|
||||||
- that only `discriminator`'s container changes the state directory's owner;
|
- that only `servewaf`'s container changes the state directory's owner;
|
||||||
- with `discriminator` stopped, the app still answering through `rproxy`;
|
- with `servewaf` stopped, the app still answering through `rproxy`;
|
||||||
- with `discriminator` started again, bans applying again;
|
- with `servewaf` started again, bans applying again;
|
||||||
- orderly stops with `sv stop` and `docker stop`;
|
- orderly stops with `sv stop` and `docker stop`;
|
||||||
- bans surviving a new container on the same volume;
|
- bans surviving a new container on the same volume;
|
||||||
- a setting given as a file.
|
- a setting given as a file.
|
||||||
@@ -747,7 +703,7 @@ on the host.
|
|||||||
- [`docs/settings.md`](docs/settings.md): every setting and the values it takes.
|
- [`docs/settings.md`](docs/settings.md): every setting and the values it takes.
|
||||||
- [`docs/rules.md`](docs/rules.md): the rule file format and the default file.
|
- [`docs/rules.md`](docs/rules.md): the rule file format and the default file.
|
||||||
- [`docs/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under
|
- [`docs/admin-endpoints.md`](docs/admin-endpoints.md): the endpoints under
|
||||||
`/_sww/`, with examples.
|
`/_smallwebwaf/`, with examples.
|
||||||
- [`docs/request-log.md`](docs/request-log.md): the request log's fields.
|
- [`docs/request-log.md`](docs/request-log.md): the request log's fields.
|
||||||
- [`docs/alerts.md`](docs/alerts.md): the alert events and what each destination
|
- [`docs/alerts.md`](docs/alerts.md): the alert events and what each destination
|
||||||
gets.
|
gets.
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Command smallwebwaf is a web application firewall. `smallwebwaf rproxy` is
|
||||||
|
// the reverse proxy in front of one app, which asks servewaf about each request.
|
||||||
|
// `smallwebwaf servewaf` decides, for many sites, and keeps what lasts.
|
||||||
|
// `smallwebwaf healthcheck` is the container's health check. Everything it does
|
||||||
|
// is in internal/smallwebwaf.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is the version of the binary, set when it is built with
|
||||||
|
// -ldflags "-X main.Version=...".
|
||||||
|
var Version = "dev" //nolint:gochecknoglobals // the linker sets it
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(smallwebwaf.Main(Version))
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// Command sww is a web application firewall. `sww rproxy` is the reverse
|
|
||||||
// proxy in front of one app, which asks discriminator about each request.
|
|
||||||
// `sww discriminator` decides, for many sites, and keeps what lasts. `sww
|
|
||||||
// healthcheck` is the container's health check. Everything it does is in
|
|
||||||
// internal/sww.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/sww"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Version is the version of the binary, set when it is built with
|
|
||||||
// -ldflags "-X main.Version=...".
|
|
||||||
var Version = "dev" //nolint:gochecknoglobals // the linker sets it
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
os.Exit(sww.Main(Version))
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
# A real app names the smallwebwaf image by digest. This one takes the
|
# A real app names the smallwebwaf image by digest. This one takes the
|
||||||
# image script/example-app has just built, or else the one `make docker`
|
# image script/example-app has just built, or else the one `make docker`
|
||||||
# builds.
|
# builds.
|
||||||
ARG SWW_IMAGE=smallwebwaf
|
ARG SMALLWEBWAF_IMAGE=smallwebwaf
|
||||||
FROM ${SWW_IMAGE}
|
FROM ${SMALLWEBWAF_IMAGE}
|
||||||
|
|
||||||
# Packages the app needs, from the nixpkgs in the image.
|
# Packages the app needs, from the nixpkgs in the image.
|
||||||
RUN nix-env -iA nixpkgs.busybox
|
RUN nix-env -iA nixpkgs.busybox
|
||||||
|
|||||||
+26
-26
@@ -1,60 +1,60 @@
|
|||||||
# Admin endpoints
|
# Admin endpoints
|
||||||
|
|
||||||
The endpoints under `/_sww/`, what each takes and what each answers.
|
The endpoints under `/_smallwebwaf/`, what each takes and what each answers.
|
||||||
`sww discriminator` answers them at its own address, on the private network.
|
`smallwebwaf servewaf` answers them at its own address, on the private network.
|
||||||
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work.
|
"Admin endpoints" in [`SPEC.md`](../SPEC.md) says how their tokens work.
|
||||||
|
|
||||||
- `GET /_sww/healthz`: `200` and `ok`, to anyone.
|
- `GET /_smallwebwaf/healthz`: `200` and `ok`, to anyone.
|
||||||
- `GET /_sww/metrics`: the metrics, for `SWW_METRICS_TOKEN` (see
|
- `GET /_smallwebwaf/metrics`: the metrics, for `SWWAF_METRICS_TOKEN` (see
|
||||||
[`metrics.md`](metrics.md)).
|
[`metrics.md`](metrics.md)).
|
||||||
- `GET /_sww/bans`: every ban held, under `bans`, each as `bans.json` lists it.
|
- `GET /_smallwebwaf/bans`: every ban held, under `bans`, each as `bans.json`
|
||||||
Needs `SWW_ADMIN_TOKEN`, as do the three below.
|
lists it. Needs `SWWAF_ADMIN_TOKEN`, as do the three below.
|
||||||
- `POST /_sww/bans`: bans a netblock. The body is a JSON object of `netblock`,
|
- `POST /_smallwebwaf/bans`: bans a netblock. The body is a JSON object of
|
||||||
`duration` and, if you like, `reason`. `netblock` is a netblock such as
|
`netblock`, `duration` and, if you like, `reason`. `netblock` is a netblock
|
||||||
`203.0.113.0/24`, or a client's address, which bans its IPv4 netblock
|
such as `203.0.113.0/24`, or a client's address, which bans its IPv4 netblock
|
||||||
(`SWW_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such
|
(`SWWAF_BAN_SCOPE_V4_PREFIX`) or its IPv6 group. `duration` is a duration such
|
||||||
as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is
|
as `1h` or `7d`, or `permanent`. A bad body is answered `400`, saying what is
|
||||||
wrong:
|
wrong:
|
||||||
- one that is not such an object, or has another field;
|
- one that is not such an object, or has another field;
|
||||||
- one longer than 4 KiB;
|
- one longer than 4 KiB;
|
||||||
- one that names an IPv4-mapped netblock, or an address with a zone.
|
- one that names an IPv4-mapped netblock, or an address with a zone.
|
||||||
- `DELETE /_sww/bans/<client>`: lifts every active ban covering the address
|
- `DELETE /_smallwebwaf/bans/<client>`: lifts every active ban covering the
|
||||||
`<client>`. It answers `404` when none is active.
|
address `<client>`. It answers `404` when none is active.
|
||||||
- `GET /_sww/clients/<ip>`: what `discriminator` knows of the client. Under
|
- `GET /_smallwebwaf/clients/<ip>`: what `servewaf` knows of the client. Under
|
||||||
`client`, its counters and history as `clients.json` holds them, or `null`
|
`client`, its counters and history as `clients.json` holds them, or `null`
|
||||||
when `clients.json` does not hold it. Under `bans`, every ban covering the
|
when `clients.json` does not hold it. Under `bans`, every ban covering the
|
||||||
address, with its notes.
|
address, with its notes.
|
||||||
- `POST /_sww/report`: takes `rproxy`'s report, without a token. It answers
|
- `POST /_smallwebwaf/report`: takes `rproxy`'s report, without a token. It
|
||||||
`204`, or `400` to a body that is no report (see "The report" in
|
answers `204`, or `400` to a body that is no report (see "The report" in
|
||||||
[`SPEC.md`](../SPEC.md)).
|
[`SPEC.md`](../SPEC.md)).
|
||||||
|
|
||||||
No check runs on these requests. A missing or wrong token is answered `401`. An
|
No check runs on these requests. A missing or wrong token is answered `401`. An
|
||||||
endpoint whose token is unset answers `404`, as does any other path.
|
endpoint whose token is unset answers `404`, as does any other path.
|
||||||
|
|
||||||
`sww rproxy` answers only the health check of these, at its own address. It asks
|
`smallwebwaf rproxy` answers only the health check of these, at its own address.
|
||||||
`discriminator` about any other request under `/_sww/`. One that passes the
|
It asks `servewaf` about any other request under `/_smallwebwaf/`. One that
|
||||||
checks is answered `404`, so that the app never sees that prefix (see "`rproxy`"
|
passes the checks is answered `404`, so that the app never sees that prefix (see
|
||||||
in [`SPEC.md`](../SPEC.md)).
|
"`rproxy`" in [`SPEC.md`](../SPEC.md)).
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
With `SWW_ADMIN_TOKEN` set, the token in `$TOKEN`, and `discriminator` reached
|
With `SWWAF_ADMIN_TOKEN` set, the token in `$TOKEN`, and `servewaf` reached as
|
||||||
as `discriminator:8080` on its network:
|
`servewaf:8080` on its network:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Every ban.
|
# Every ban.
|
||||||
curl -H "Authorization: Bearer $TOKEN" http://discriminator:8080/_sww/bans
|
curl -H "Authorization: Bearer $TOKEN" http://servewaf:8080/_smallwebwaf/bans
|
||||||
|
|
||||||
# Ban 203.0.113.0/24 for seven days.
|
# Ban 203.0.113.0/24 for seven days.
|
||||||
curl -H "Authorization: Bearer $TOKEN" \
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
--json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \
|
--json '{"netblock": "203.0.113.0/24", "duration": "7d", "reason": "probes for logins"}' \
|
||||||
http://discriminator:8080/_sww/bans
|
http://servewaf:8080/_smallwebwaf/bans
|
||||||
|
|
||||||
# Lift the bans on 203.0.113.9.
|
# Lift the bans on 203.0.113.9.
|
||||||
curl -H "Authorization: Bearer $TOKEN" -X DELETE \
|
curl -H "Authorization: Bearer $TOKEN" -X DELETE \
|
||||||
http://discriminator:8080/_sww/bans/203.0.113.9
|
http://servewaf:8080/_smallwebwaf/bans/203.0.113.9
|
||||||
|
|
||||||
# What discriminator knows of 203.0.113.9.
|
# What servewaf knows of 203.0.113.9.
|
||||||
curl -H "Authorization: Bearer $TOKEN" \
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
http://discriminator:8080/_sww/clients/203.0.113.9
|
http://servewaf:8080/_smallwebwaf/clients/203.0.113.9
|
||||||
```
|
```
|
||||||
+10
-10
@@ -6,7 +6,7 @@ back or dropped.
|
|||||||
|
|
||||||
## Events
|
## Events
|
||||||
|
|
||||||
- `ban`: a ban `discriminator` made.
|
- `ban`: a ban `servewaf` made.
|
||||||
- `permanent_ban`: a permanent ban it made, or an existing ban made permanent by
|
- `permanent_ban`: a permanent ban it made, or an existing ban made permanent by
|
||||||
a request.
|
a request.
|
||||||
- `waf_block`: the Core Rule Set scored a request at or over the threshold. The
|
- `waf_block`: the Core Rule Set scored a request at or over the threshold. The
|
||||||
@@ -34,7 +34,7 @@ on one line:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"instance": "fsn1/discriminator",
|
"instance": "fsn1/servewaf",
|
||||||
"time": "2026-10-06T12:00:50.123461Z",
|
"time": "2026-10-06T12:00:50.123461Z",
|
||||||
"event": "ban",
|
"event": "ban",
|
||||||
"host": "app.example",
|
"host": "app.example",
|
||||||
@@ -77,11 +77,11 @@ on one line:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `instance`: `SWW_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
- `instance`: `SWWAF_INSTANCE_NAME`. `time`: when the alert was raised. `event`:
|
||||||
one of the events above.
|
one of the events above.
|
||||||
- `host`: the site of the request that raised the alert, since one
|
- `host`: the site of the request that raised the alert, since one `servewaf`
|
||||||
`discriminator` serves many sites. It is left out of `source_failure`,
|
serves many sites. It is left out of `source_failure`, `file_error` and
|
||||||
`file_error` and `summary`.
|
`summary`.
|
||||||
- `client`: the address whose request raised the alert. Empty for
|
- `client`: the address whose request raised the alert. Empty for
|
||||||
`source_failure`, `file_error` and `summary`.
|
`source_failure`, `file_error` and `summary`.
|
||||||
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
|
- `netblock`: empty for `source_failure`, `file_error` and `summary`; otherwise
|
||||||
@@ -111,8 +111,8 @@ on one line:
|
|||||||
## Slack and ntfy
|
## Slack and ntfy
|
||||||
|
|
||||||
Slack and ntfy get a message with a title and a text. The title is the instance
|
Slack and ntfy get a message with a title and a text. The title is the instance
|
||||||
and the event, such as `fsn1/discriminator: ban`. The text is the `reason`, then
|
and the event, such as `fsn1/servewaf: ban`. The text is the `reason`, then one
|
||||||
one line for each of these that the alert has:
|
line for each of these that the alert has:
|
||||||
|
|
||||||
- the host, the client, the netblock and the country;
|
- the host, the client, the netblock and the country;
|
||||||
- the file, the source, the error and the mode from `detail`;
|
- the file, the source, the error and the mode from `detail`;
|
||||||
@@ -122,8 +122,8 @@ Slack gets the title in bold and the text below it, with `&`, `<` and `>`
|
|||||||
escaped. ntfy gets the text as the message, with these headers:
|
escaped. ntfy gets the text as the message, with these headers:
|
||||||
|
|
||||||
- `Title`: the title;
|
- `Title`: the title;
|
||||||
- `Authorization`: the token, as `Bearer <token>`, while `SWW_ALERT_NTFY_TOKEN`
|
- `Authorization`: the token, as `Bearer <token>`, while
|
||||||
is set;
|
`SWWAF_ALERT_NTFY_TOKEN` is set;
|
||||||
- `Priority` and `Tags`: by event, as listed below.
|
- `Priority` and `Tags`: by event, as listed below.
|
||||||
|
|
||||||
The priority and tag by event:
|
The priority and tag by event:
|
||||||
|
|||||||
+9
-8
@@ -1,7 +1,7 @@
|
|||||||
# What the image holds
|
# What the image holds
|
||||||
|
|
||||||
The contents of the `smallwebwaf` image, and how each part is pinned. The image
|
The contents of the `smallwebwaf` image, and how each part is pinned. The image
|
||||||
runs `rproxy` by default, or `discriminator` with `SWW_COMMAND=discriminator`.
|
runs `rproxy` by default, or `servewaf` with `SMALLWEBWAF_COMMAND=servewaf`.
|
||||||
"Deployment" in [`SPEC.md`](../SPEC.md) says why each part is there and why it
|
"Deployment" in [`SPEC.md`](../SPEC.md) says why each part is there and why it
|
||||||
is pinned that way.
|
is pinned that way.
|
||||||
|
|
||||||
@@ -15,12 +15,13 @@ is pinned that way.
|
|||||||
Unpacked, nixpkgs takes about 500 MiB.
|
Unpacked, nixpkgs takes about 500 MiB.
|
||||||
- `runsvinit` as the entrypoint, built from `github.com/peterbourgon/runsvinit`
|
- `runsvinit` as the entrypoint, built from `github.com/peterbourgon/runsvinit`
|
||||||
at a fixed commit hash.
|
at a fixed commit hash.
|
||||||
- The `sww` binary, and the user `sww` (uid and gid 65532).
|
- The `smallwebwaf` binary, and the user `smallwebwaf` (uid and gid 65532).
|
||||||
- The service directory `/etc/service/sww`, with the `run` script
|
- The service directory `/etc/service/smallwebwaf`, with the `run` script
|
||||||
`share/sww.run`.
|
`share/smallwebwaf.run`.
|
||||||
- `/var/lib/sww` for `discriminator`'s state files, and `/etc/sww/rules.d` with
|
- `/var/lib/smallwebwaf` for `servewaf`'s state files, and
|
||||||
the default rule file `share/rules.d/00-default.rules`.
|
`/etc/smallwebwaf/rules.d` with the default rule file
|
||||||
- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `sww healthcheck`.
|
`share/rules.d/00-default.rules`.
|
||||||
|
- `EXPOSE 8080`, and the `HEALTHCHECK` that runs `smallwebwaf healthcheck`.
|
||||||
|
|
||||||
## Pins
|
## Pins
|
||||||
|
|
||||||
@@ -32,4 +33,4 @@ is pinned that way.
|
|||||||
- `runsvinit`: the archived repository, unchanged since 2015, at its last commit
|
- `runsvinit`: the archived repository, unchanged since 2015, at its last commit
|
||||||
(`b4b2c78`, eight commits after `v2.0.0`), by commit hash, with a `go.mod` the
|
(`b4b2c78`, eight commits after `v2.0.0`), by commit hash, with a `go.mod` the
|
||||||
build writes.
|
build writes.
|
||||||
- The Go image that builds `sww`: by digest.
|
- The Go image that builds `smallwebwaf`: by digest.
|
||||||
+49
-43
@@ -1,84 +1,90 @@
|
|||||||
# The metrics
|
# The metrics
|
||||||
|
|
||||||
What `GET /_sww/metrics` answers with, at `discriminator`'s own address.
|
What `GET /_smallwebwaf/metrics` answers with, at `servewaf`'s own address.
|
||||||
"Metrics endpoint" in [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus
|
"Metrics endpoint" in [`SPEC.md`](../SPEC.md) describes the endpoint. Prometheus
|
||||||
keeps the `instance` label as `exported_instance` unless the scrape sets
|
keeps the `instance` label as `exported_instance` unless the scrape sets
|
||||||
`honor_labels: true`. `rproxy` has no metrics.
|
`honor_labels: true`. `rproxy` has no metrics.
|
||||||
|
|
||||||
Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts
|
|
||||||
body bytes, never headers, whatever `SWW_BYTES_COUNT` says.
|
|
||||||
|
|
||||||
## Traffic
|
## Traffic
|
||||||
|
|
||||||
- `sww_requests_total`, `sww_request_bytes_total` and
|
- `smallwebwaf_requests_total`, `smallwebwaf_request_bytes_total` and
|
||||||
`sww_response_bytes_total`, by `status_class` such as `2xx` and by `action`;
|
`smallwebwaf_response_bytes_total`, by `status_class` such as `2xx` and by
|
||||||
- `sww_request_duration_seconds` and `sww_upstream_duration_seconds`, as
|
`action`;
|
||||||
histograms;
|
- `smallwebwaf_request_duration_seconds` and
|
||||||
- `sww_requests_in_flight`: the questions and requests to its own endpoints that
|
`smallwebwaf_upstream_duration_seconds`, as histograms;
|
||||||
`discriminator` is handling.
|
- `smallwebwaf_requests_in_flight`: the questions and requests to its own
|
||||||
|
endpoints that `servewaf` is handling.
|
||||||
|
|
||||||
## Limits and bans
|
## Limits and bans
|
||||||
|
|
||||||
- `sww_rate_limit_hits_total`, by `window` and by `kind` (`requests`, `bytes` or
|
- `smallwebwaf_rate_limit_hits_total`, by `window` and by `kind` (`requests`,
|
||||||
`refusals`);
|
`bytes` or `refusals`);
|
||||||
- `sww_offences_total`, by `kind`;
|
- `smallwebwaf_offences_total`, by `kind`;
|
||||||
- `sww_bans_made_total`, by `cause`;
|
- `smallwebwaf_bans_made_total`, by `cause`;
|
||||||
- `sww_active_bans` and `sww_permanent_bans`.
|
- `smallwebwaf_active_bans` and `smallwebwaf_permanent_bans`.
|
||||||
|
|
||||||
## Attack detection
|
## Attack detection
|
||||||
|
|
||||||
- `sww_rule_matches_total`, by `rule_id` and `action`, and `sww_rules_loaded`;
|
- `smallwebwaf_rule_matches_total`, by `rule_id` and `action`, and
|
||||||
- `sww_waf_matches_total`, by `mode` and `rule_id`, for the rules that fired.
|
`smallwebwaf_rules_loaded`;
|
||||||
|
- `smallwebwaf_waf_matches_total`, by `mode` and `rule_id`, for the rules that
|
||||||
|
fired.
|
||||||
|
|
||||||
## Countries and AS numbers
|
## Countries and AS numbers
|
||||||
|
|
||||||
For the `SWW_METRICS_TOP_N` busiest of each, the rest as `other`:
|
For the `SWWAF_METRICS_TOP_N` busiest of each, the rest as `other`:
|
||||||
|
|
||||||
- `sww_country_requests_total`, `sww_country_request_bytes_total`,
|
- `smallwebwaf_country_requests_total`,
|
||||||
`sww_country_response_bytes_total` and `sww_country_list_refusals_total`, by
|
`smallwebwaf_country_request_bytes_total`,
|
||||||
`country`;
|
`smallwebwaf_country_response_bytes_total` and
|
||||||
- `sww_asn_requests_total`, `sww_asn_request_bytes_total` and
|
`smallwebwaf_country_list_refusals_total`, by `country`;
|
||||||
`sww_asn_response_bytes_total`, by `asn`.
|
- `smallwebwaf_asn_requests_total`, `smallwebwaf_asn_request_bytes_total` and
|
||||||
|
`smallwebwaf_asn_response_bytes_total`, by `asn`.
|
||||||
|
|
||||||
## Lookups
|
## Lookups
|
||||||
|
|
||||||
- `sww_geojs_requests_total` and `sww_geojs_failures_total`;
|
- `smallwebwaf_geojs_requests_total` and `smallwebwaf_geojs_failures_total`;
|
||||||
- `sww_geojs_unanswered_total`: the requests that needed an answer and went on
|
- `smallwebwaf_geojs_unanswered_total`: the requests that needed an answer and
|
||||||
without it;
|
went on without it;
|
||||||
- with the file source, `sww_lookup_database_last_read_timestamp_seconds` and
|
- with the file source,
|
||||||
`sww_lookup_database_read_failures_total`.
|
`smallwebwaf_lookup_database_last_read_timestamp_seconds` and
|
||||||
|
`smallwebwaf_lookup_database_read_failures_total`.
|
||||||
|
|
||||||
## Reputation sources
|
## Reputation sources
|
||||||
|
|
||||||
By `source`: each list's URL, each zone, and `abuseipdb`:
|
By `source`: each list's URL, each zone, and `abuseipdb`:
|
||||||
|
|
||||||
- `sww_reputation_hits_total`, `sww_reputation_queries_total` and
|
- `smallwebwaf_reputation_hits_total`, `smallwebwaf_reputation_queries_total`
|
||||||
`sww_reputation_failures_total`;
|
and `smallwebwaf_reputation_failures_total`;
|
||||||
- `sww_reputation_last_fetch_timestamp_seconds`, for a list;
|
- `smallwebwaf_reputation_last_fetch_timestamp_seconds`, for a list;
|
||||||
- `sww_reputation_daily_budget_remaining`, for AbuseIPDB.
|
- `smallwebwaf_reputation_daily_budget_remaining`, for AbuseIPDB.
|
||||||
|
|
||||||
## State files
|
## State files
|
||||||
|
|
||||||
By `file`:
|
By `file`:
|
||||||
|
|
||||||
- `sww_state_file_writes_total`, `sww_state_file_write_failures_total`,
|
- `smallwebwaf_state_file_writes_total`,
|
||||||
`sww_state_file_last_write_timestamp_seconds` and `sww_state_file_size_bytes`;
|
`smallwebwaf_state_file_write_failures_total`,
|
||||||
- `sww_state_file_edits_taken_in_total` and
|
`smallwebwaf_state_file_last_write_timestamp_seconds` and
|
||||||
`sww_state_file_edits_set_aside_total`;
|
`smallwebwaf_state_file_size_bytes`;
|
||||||
- `sww_tracked_clients`: the clients in the table.
|
- `smallwebwaf_state_file_edits_taken_in_total` and
|
||||||
|
`smallwebwaf_state_file_edits_set_aside_total`;
|
||||||
|
- `smallwebwaf_tracked_clients`: the clients in the table.
|
||||||
|
|
||||||
## Remote log, alerts and the process
|
## Remote log, alerts and the process
|
||||||
|
|
||||||
- while a syslog server is set, `sww_remote_log_lines_sent_total`,
|
- while a syslog server is set, `smallwebwaf_remote_log_lines_sent_total`,
|
||||||
`sww_remote_log_lines_dropped_total` and `sww_remote_log_buffer_depth`;
|
`smallwebwaf_remote_log_lines_dropped_total` and
|
||||||
- by `destination`, `sww_alerts_sent_total`, `sww_alerts_failed_total`,
|
`smallwebwaf_remote_log_buffer_depth`;
|
||||||
`sww_alerts_suppressed_total` and `sww_alerts_dropped_total`;
|
- by `destination`, `smallwebwaf_alerts_sent_total`,
|
||||||
|
`smallwebwaf_alerts_failed_total`, `smallwebwaf_alerts_suppressed_total` and
|
||||||
|
`smallwebwaf_alerts_dropped_total`;
|
||||||
- Go's own `go_` metrics and the process's `process_` metrics.
|
- Go's own `go_` metrics and the process's `process_` metrics.
|
||||||
|
|
||||||
## When a request is counted
|
## When a request is counted
|
||||||
|
|
||||||
`discriminator` counts a denied request at its decision, and a request to its
|
`servewaf` counts a denied request at its decision, and a request to its own
|
||||||
own endpoints when it is answered. It counts an allowed request when its report
|
endpoints when it is answered. It counts an allowed request when its report
|
||||||
comes, with the status, bytes and times the report gives. These are not counted:
|
comes, with the status, bytes and times the report gives. These are not counted:
|
||||||
|
|
||||||
- a report itself;
|
- a report itself;
|
||||||
|
|||||||
+47
-90
@@ -1,32 +1,32 @@
|
|||||||
# The request log's fields
|
# The request log's fields
|
||||||
|
|
||||||
The fields of the JSON objects `rproxy` and `discriminator` write about each
|
The fields of the JSON objects `rproxy` and `servewaf` write about each request.
|
||||||
request. "Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line
|
"Request log" and "The log" in [`SPEC.md`](../SPEC.md) say when a line is
|
||||||
is written and how the log is sent to a syslog server. A field that does not
|
written and how the log is sent to a syslog server. A field that does not apply
|
||||||
apply to a request is left out.
|
to a request is left out.
|
||||||
|
|
||||||
## `discriminator`'s lines
|
## `servewaf`'s lines
|
||||||
|
|
||||||
`sww discriminator` writes two lines for each question it answers, joined by
|
`smallwebwaf servewaf` writes two lines for each question it answers, joined by
|
||||||
`request_id`. The field `discriminator_line` says which of the two a line is:
|
`request_id`. The field `servewaf_line` says which of the two a line is:
|
||||||
|
|
||||||
- `decision`: the line it writes once it has answered the question;
|
- `decision`: the line it writes once it has answered the question;
|
||||||
- `report`: the line it writes when `rproxy`'s report comes, with everything
|
- `report`: the line it writes when `rproxy`'s report comes, with everything
|
||||||
known. The repetition is intended.
|
known. The repetition is intended.
|
||||||
|
|
||||||
A request to its own endpoints writes one line, without `discriminator_line`. A
|
A request to its own endpoints writes one line, without `servewaf_line`. A
|
||||||
report writes no line but the `report` line of the request it names.
|
report writes no line but the `report` line of the request it names.
|
||||||
|
|
||||||
These questions write no line:
|
These questions write no line:
|
||||||
|
|
||||||
- a question refused with `400` (see "The question" under "`discriminator`" in
|
- a question refused with `400` (see "The question" under "`servewaf`" in
|
||||||
[`SPEC.md`](../SPEC.md));
|
[`SPEC.md`](../SPEC.md));
|
||||||
- a question whose body stopped arriving, which gets no answer.
|
- a question whose body stopped arriving, which gets no answer.
|
||||||
|
|
||||||
A `report` line:
|
A `report` line:
|
||||||
|
|
||||||
```
|
```
|
||||||
{"type":"request","discriminator_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/discriminator","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"waf_score":0,"duration_total":3.217,"duration_checks":0.041,"duration_waf":0.029,"duration_upstream_total":3.104}
|
{"type":"request","servewaf_line":"report","time":"2026-10-03T12:00:00.123Z","instance":"fsn1/servewaf","client_ip":"203.0.113.9","method":"GET","scheme":"https","host":"app.example","path":"/","query":"","protocol":"HTTP/1.1","status":200,"request_bytes":0,"response_bytes":5120,"referer":"","user_agent":"curl/8.9.1","request_id":"7Q2NHZ4KJ3VXW5YB6R3MEFTD2A","peer_ip":"172.18.0.2","forwarded_for":"203.0.113.9","client_group":"203.0.113.9/32","asn":"AS64496","as_name":"Example Net","country":"DE","request_headers":{"accept":"*/*"},"action":"forward","counts":{"minute":1,"hour":12,"day":40,"minute_bytes":5120,"hour_bytes":61440,"day_bytes":204800},"waf_score":0,"duration_total":3.217,"duration_checks":0.041,"duration_waf":0.029,"duration_upstream_total":3.104}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Every line
|
### Every line
|
||||||
@@ -41,10 +41,10 @@ Every one of these lines has:
|
|||||||
|
|
||||||
### Standard web log fields
|
### Standard web log fields
|
||||||
|
|
||||||
- `time`: when `discriminator` had read the question's body, RFC 3339 with
|
- `time`: when `servewaf` had read the question's body, RFC 3339 with
|
||||||
milliseconds, in UTC. The `report` line gives the same time as the `decision`
|
milliseconds, in UTC. The `report` line gives the same time as the `decision`
|
||||||
line.
|
line.
|
||||||
- `instance`: `SWW_INSTANCE_NAME`.
|
- `instance`: `SWWAF_INSTANCE_NAME`.
|
||||||
- `client_ip`: the visitor's address, as `rproxy` worked it out.
|
- `client_ip`: the visitor's address, as `rproxy` worked it out.
|
||||||
- `method`, `path` and `query`: the visitor's request.
|
- `method`, `path` and `query`: the visitor's request.
|
||||||
- `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of
|
- `scheme`: how the visitor reached `rproxy` or the trusted proxy in front of
|
||||||
@@ -52,9 +52,8 @@ Every one of these lines has:
|
|||||||
- `host`: the site the visitor asked for.
|
- `host`: the site the visitor asked for.
|
||||||
- `protocol`: the question's own, `HTTP/1.1`.
|
- `protocol`: the question's own, `HTTP/1.1`.
|
||||||
- `status`:
|
- `status`:
|
||||||
- on the `decision` line, what `discriminator` answered: `200` for `allow`,
|
- on the `decision` line, what `servewaf` answered: `200` for `allow`, the
|
||||||
the status of `deny`, or `0` for `close`, as the visitor is then sent
|
status of `deny`, or `0` for `close`, as the visitor is then sent nothing;
|
||||||
nothing;
|
|
||||||
- on the `report` line, what the visitor got, `0` if nothing.
|
- on the `report` line, what the visitor got, `0` if nothing.
|
||||||
- `request_bytes`:
|
- `request_bytes`:
|
||||||
- on the `decision` line, how much of the question's body the Core Rule Set
|
- on the `decision` line, how much of the question's body the Core Rule Set
|
||||||
@@ -62,7 +61,7 @@ Every one of these lines has:
|
|||||||
- on the `report` line, the bytes of the visitor's body that `rproxy` read,
|
- on the `report` line, the bytes of the visitor's body that `rproxy` read,
|
||||||
with what a WebSocket carried from the visitor.
|
with what a WebSocket carried from the visitor.
|
||||||
- `response_bytes`:
|
- `response_bytes`:
|
||||||
- on the `decision` line, the body of `discriminator`'s answer;
|
- on the `decision` line, the body of `servewaf`'s answer;
|
||||||
- on the `report` line, the bytes of the answer's body, with what a
|
- on the `report` line, the bytes of the answer's body, with what a
|
||||||
WebSocket carried from the app.
|
WebSocket carried from the app.
|
||||||
- `referer` and `user_agent`.
|
- `referer` and `user_agent`.
|
||||||
@@ -79,67 +78,35 @@ Every one of these lines has:
|
|||||||
- lookups are off;
|
- lookups are off;
|
||||||
- the client is not looked up;
|
- the client is not looked up;
|
||||||
- the source cannot place the client;
|
- the source cannot place the client;
|
||||||
- GeoJS had not answered by the time `discriminator` answered.
|
- GeoJS had not answered by the time `servewaf` answered.
|
||||||
- `content_type`: the visitor's `Content-Type`.
|
- `content_type`: the visitor's `Content-Type`.
|
||||||
- `content_length`: the body length the visitor announced.
|
- `content_length`: the body length the visitor announced.
|
||||||
- `request_headers`: the headers listed in `SWW_LOG_REQUEST_HEADERS`, by name in
|
- `request_headers`: the headers listed in `SWWAF_LOG_REQUEST_HEADERS`, by name
|
||||||
lower case.
|
in lower case.
|
||||||
- `has_authorization` and `has_cookie`: true when the request had those headers.
|
- `has_authorization` and `has_cookie`: true when the request had those headers.
|
||||||
Their values are never logged.
|
Their values are never logged.
|
||||||
|
|
||||||
### Response detail
|
### Response detail
|
||||||
|
|
||||||
- `response_content_type`: the type of `discriminator`'s own answer.
|
- `response_content_type`: the type of `servewaf`'s own answer.
|
||||||
- `aborted`: true when `rproxy` went away before the answer.
|
- `aborted`: true when `rproxy` went away before the answer.
|
||||||
|
|
||||||
### Decision
|
### Decision
|
||||||
|
|
||||||
- `action`: what was done:
|
- `action`: what was done:
|
||||||
- `forward`: answered `allow`, so that `rproxy` passes it to the app;
|
- `forward`: answered `allow`, so that `rproxy` passes it to the app;
|
||||||
- `client_denied`: refused for `SWW_DENY_NETS`, for a reputation source with
|
- `denied`: refused for `SWWAF_DENY_NETS`, or for a reputation source with
|
||||||
the action `deny`, or for a tag in `SWW_TAG_DENY`;
|
the action `deny`;
|
||||||
- `temporarily_banned`: refused under a ban that runs out, or for a `ban`
|
- `banned`: refused under a ban, or for a `ban` rule, a trap path or a
|
||||||
rule, a trap path or a CrowdSec decision that made such a ban;
|
CrowdSec decision, each of which bans the client;
|
||||||
- `permanently_banned`: refused under a ban that never runs out, including a
|
- `country_denied`, `rate_limited`, `rule_blocked` and `waf_blocked`:
|
||||||
ban this request made or made permanent;
|
refused by that check;
|
||||||
- `country_denied`, `client_is_rate_limited`, `blocked_by_block_rule` and
|
- `too_large`: over a header limit;
|
||||||
`waf_blocked`: refused by that check;
|
- `admin`: answered at one of `servewaf`'s own endpoints, or `404` for a
|
||||||
- `request_header_too_large`: over a header limit;
|
path under `/_smallwebwaf/`.
|
||||||
- `admin`: answered at one of `discriminator`'s own endpoints, or `404` for
|
|
||||||
a 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
|
||||||
request it would have refused. `action` then names what was done, normally
|
request it would have refused. `action` then names what was done, normally
|
||||||
`forward`.
|
`forward`.
|
||||||
- `heuristic`: the heuristic that refused the request, in `observe` mode too. It
|
|
||||||
is left out when none did.
|
|
||||||
- `heuristic_figures`: an object of the figures that heuristic acted on. Each
|
|
||||||
heuristic and its figures:
|
|
||||||
- `deny_nets`: `netblock`, the entry of `SWW_DENY_NETS` the client is in;
|
|
||||||
- `active_ban`: `netblock`, the ban's netblock, and `ban_expires`;
|
|
||||||
- `countries`: `country`, and `setting`, the country list that refused it;
|
|
||||||
- `tag_deny`: `tag`, the tag of `SWW_TAG_DENY` the request carries;
|
|
||||||
- `blocklists`: `lists`, the blocklist URLs that list the client;
|
|
||||||
- `crowdsec`: `scenario`, the decision's scenario, and `expires`, when the
|
|
||||||
decision ends;
|
|
||||||
- `dnsbl`: `zones`, the DNS blocklist zones that list the client, their keys
|
|
||||||
masked;
|
|
||||||
- `abuseipdb`: `score`, and `min_score`, `SWW_ABUSEIPDB_MIN_SCORE`;
|
|
||||||
- `rate_limits`: `window`, `count` and `limit`;
|
|
||||||
- `trap_paths`: `path`, the trap path asked for;
|
|
||||||
- `rule_files`: `rule_id` and `rule_action`, of the rule that refused it;
|
|
||||||
- `header_limits`: `fields`, `fields_limit`, `cookies` and `cookies_limit`;
|
|
||||||
- `core_rule_set`: `score`, and `threshold`, `SWW_WAF_ANOMALY_THRESHOLD`;
|
|
||||||
- `refusal_limit`: `window`, `count` and `limit`, of the error burst;
|
|
||||||
- `byte_limits`: `window`, `count` and `limit`, the count in bytes.
|
|
||||||
- `after_answer_heuristic`: the heuristic that banned the client after
|
|
||||||
`discriminator` answered the request, in `observe` mode too: `refusal_limit`
|
|
||||||
or `byte_limits`.
|
|
||||||
- `after_answer_heuristic_figures`: an object of the figures that heuristic
|
|
||||||
acted on, as listed above.
|
|
||||||
|
|
||||||
`byte_limits` bans when the report comes, so only the `report` line names
|
|
||||||
it.
|
|
||||||
|
|
||||||
- `limit_percent` and `limit_percent_setting`: the client's percentage of the
|
- `limit_percent` and `limit_percent_setting`: the client's percentage of the
|
||||||
rate limits and the setting that lowered it, when one did. `bytes_percent` and
|
rate limits and the setting that lowered it, when one did. `bytes_percent` and
|
||||||
`bytes_percent_setting` do the same for the byte limits.
|
`bytes_percent_setting` do the same for the byte limits.
|
||||||
@@ -147,22 +114,20 @@ Every one of these lines has:
|
|||||||
included. It is left out when rate limiting did not count the request. It
|
included. It is left out when rate limiting did not count the request. It
|
||||||
holds:
|
holds:
|
||||||
- `minute`, `hour` and `day`: requests;
|
- `minute`, `hour` and `day`: requests;
|
||||||
- `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes
|
- `minute_bytes`, `hour_bytes` and `day_bytes`: bytes.
|
||||||
`SWW_BYTES_COUNT` names, in that window.
|
|
||||||
|
|
||||||
A count can have a fraction (see "Counting method" in
|
A count can have a fraction (see "Counting method" in
|
||||||
[`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its
|
[`SPEC.md`](../SPEC.md)). The bytes of an allowed request are counted when
|
||||||
response are added when its report comes. So on the `decision` line, the
|
its report comes. So on the `decision` line, the bytes are without this
|
||||||
three byte totals leave them out.
|
request's.
|
||||||
|
|
||||||
- `limit_hit`: the limit broken:
|
- `limit_hit`: the limit broken:
|
||||||
- `minute`, `hour` or `day` for a rate limit;
|
- `minute`, `hour` or `day` for a rate limit;
|
||||||
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
|
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
|
||||||
- `error_burst`.
|
- `error_burst`.
|
||||||
|
|
||||||
When the bytes counted for a request and its response break a limit, the
|
A request whose bytes broke a limit was passed on whole, so its `action`
|
||||||
request has been passed on whole, so its `action` stays `forward`. Only its
|
stays `forward`. Only its `report` line gives that limit.
|
||||||
`report` line gives that limit.
|
|
||||||
|
|
||||||
- `rule_ids`: the rule file rules that matched, in order, up to the one that
|
- `rule_ids`: the rule file rules that matched, in order, up to the one that
|
||||||
refused.
|
refused.
|
||||||
@@ -173,14 +138,6 @@ Every one of these lines has:
|
|||||||
- the CrowdSec decision list's URL;
|
- the CrowdSec decision list's URL;
|
||||||
- DNS blocklist zones;
|
- DNS blocklist zones;
|
||||||
- `abuseipdb`.
|
- `abuseipdb`.
|
||||||
- `tags`: the request's tags, sorted, left out when it has none. Each tag and
|
|
||||||
where it comes from is under "Request tags" in [`SPEC.md`](../SPEC.md).
|
|
||||||
- `routewatch_tags_missing`: why the request got no tags from routewatch,
|
|
||||||
present only while `SWW_ROUTEWATCH_URL` is set:
|
|
||||||
- `no_answer_yet`: routewatch has not answered about this client yet;
|
|
||||||
- `request_failed`: a query to routewatch failed within the last minute, and
|
|
||||||
the process log says why;
|
|
||||||
- `no_route`: routewatch knows no live route for the address.
|
|
||||||
- `offence`: `limit`, for a request that broke a limit.
|
- `offence`: `limit`, for a request that broke a limit.
|
||||||
- `ban_expires`: when the client's ban ends, for a request that made one or was
|
- `ban_expires`: when the client's ban ends, for a request that made one or was
|
||||||
refused under one: a time, or `permanent`.
|
refused under one: a time, or `permanent`.
|
||||||
@@ -190,8 +147,8 @@ Every one of these lines has:
|
|||||||
All in milliseconds:
|
All in milliseconds:
|
||||||
|
|
||||||
- `duration_total`:
|
- `duration_total`:
|
||||||
- on the `decision` line, from `discriminator` having read the question's
|
- on the `decision` line, from `servewaf` having read the question's body to
|
||||||
body to the line;
|
the line;
|
||||||
- on the `report` line, from `rproxy` having read the request's headers to
|
- on the `report` line, from `rproxy` having read the request's headers to
|
||||||
the end of the answer.
|
the end of the answer.
|
||||||
- `duration_checks`: to the end of the checks;
|
- `duration_checks`: to the end of the checks;
|
||||||
@@ -201,8 +158,8 @@ All in milliseconds:
|
|||||||
|
|
||||||
### Requests to its own endpoints
|
### Requests to its own endpoints
|
||||||
|
|
||||||
The line of a request to `discriminator`'s own endpoints has these fields, with
|
The line of a request to `servewaf`'s own endpoints has these fields, with these
|
||||||
these meanings where they differ:
|
meanings where they differ:
|
||||||
|
|
||||||
- `client_ip` and `peer_ip`: the request's TCP peer.
|
- `client_ip` and `peer_ip`: the request's TCP peer.
|
||||||
- `request_id`: a new random one of 26 letters and digits.
|
- `request_id`: a new random one of 26 letters and digits.
|
||||||
@@ -214,8 +171,8 @@ these meanings where they differ:
|
|||||||
|
|
||||||
## `rproxy`'s line
|
## `rproxy`'s line
|
||||||
|
|
||||||
`sww rproxy` writes one short line for each request. `request_id` joins it to
|
`smallwebwaf rproxy` writes one short line for each request. `request_id` joins
|
||||||
`discriminator`'s two. It has these fields and no others:
|
it to `servewaf`'s two. It has these fields and no others:
|
||||||
|
|
||||||
- `type`: `request`.
|
- `type`: `request`.
|
||||||
- `time`: when the request arrived, RFC 3339 with milliseconds, in UTC.
|
- `time`: when the request arrived, RFC 3339 with milliseconds, in UTC.
|
||||||
@@ -230,15 +187,15 @@ these meanings where they differ:
|
|||||||
carried from the app.
|
carried from the app.
|
||||||
- `duration_request`: milliseconds from the request's headers being read until
|
- `duration_request`: milliseconds from the request's headers being read until
|
||||||
the app had been sent the whole request, or the answer started if that was
|
the app had been sent the whole request, or the answer started if that was
|
||||||
sooner. It includes the wait for `discriminator`.
|
sooner. It includes the wait for `servewaf`.
|
||||||
- `duration_response`: milliseconds from then until the end of the answer.
|
- `duration_response`: milliseconds from then until the end of the answer.
|
||||||
- `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 `discriminator`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is
|
is `servewaf`'s answer, or the value of `SWRP_DEFAULT_DISPOSITION`. It is left
|
||||||
left out when:
|
out when:
|
||||||
- `discriminator` was not asked, as for the health check;
|
- `servewaf` was not asked, as for the health check;
|
||||||
- the visitor went away while `discriminator` was being asked;
|
- the visitor went away while `servewaf` was being asked;
|
||||||
- `discriminator`'s answer had no valid `X-Sww-Disposition`;
|
- `servewaf`'s answer had no valid `X-Smallwebwaf-Disposition`;
|
||||||
- `rproxy` could not pass on `discriminator`'s answer, and answered `502`.
|
- `rproxy` could not pass on `servewaf`'s answer, and answered `502`.
|
||||||
- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave
|
- `default_disposition`: `true` when `SWRP_DEFAULT_DISPOSITION` gave
|
||||||
`disposition`, and otherwise left out.
|
`disposition`, and otherwise left out.
|
||||||
|
|
||||||
|
|||||||
+7
-8
@@ -32,7 +32,7 @@ scanner-agent user_agent ban (?i)\b(sqlmap|nikto|nuclei|wpscan)\b
|
|||||||
## The default file
|
## The default file
|
||||||
|
|
||||||
The image ships one file, `share/rules.d/00-default.rules`, installed as
|
The image ships one file, `share/rules.d/00-default.rules`, installed as
|
||||||
`/etc/sww/rules.d/00-default.rules`:
|
`/etc/smallwebwaf/rules.d/00-default.rules`:
|
||||||
|
|
||||||
```
|
```
|
||||||
# 00-default.rules: probes no real visitor sends, anchored at the site root
|
# 00-default.rules: probes no real visitor sends, anchored at the site root
|
||||||
@@ -54,20 +54,19 @@ empty-agent user_agent log ^$
|
|||||||
|
|
||||||
## A file of your own
|
## A file of your own
|
||||||
|
|
||||||
`discriminator` reads the rule files in its own container. Rules of your own go
|
`servewaf` reads the rule files in its own container. Rules of your own go in a
|
||||||
in a file named to sort after the default, such as `50-site.rules`:
|
file named to sort after the default, such as `50-site.rules`:
|
||||||
|
|
||||||
```
|
```
|
||||||
# 50-site.rules: WordPress probes, which this site never serves
|
# 50-site.rules: WordPress probes, which this site never serves
|
||||||
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
|
wp-probe path ban (?i)^/(wp-login\.php|xmlrpc\.php|wp-admin/)
|
||||||
```
|
```
|
||||||
|
|
||||||
A `Dockerfile` built on the `smallwebwaf` image, for `discriminator` to run
|
A `Dockerfile` built on the `smallwebwaf` image, for `servewaf` to run from,
|
||||||
from, adds it:
|
adds it:
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
COPY 50-site.rules /etc/sww/rules.d/50-site.rules
|
COPY 50-site.rules /etc/smallwebwaf/rules.d/50-site.rules
|
||||||
```
|
```
|
||||||
|
|
||||||
Or mount it there. A `path` rule applies on every site behind that
|
Or mount it there. A `path` rule applies on every site behind that `servewaf`.
|
||||||
`discriminator`.
|
|
||||||
+181
-248
@@ -4,9 +4,9 @@ Every setting, its default and the values it takes, in the groups of "Settings"
|
|||||||
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
|
in [`SPEC.md`](../SPEC.md). That section gives the conventions, the settings
|
||||||
that take `off`, the `_FILE` form, and what each group does and why.
|
that take `off`, the `_FILE` form, and what each group does and why.
|
||||||
|
|
||||||
The settings that start with `SWW_` are `discriminator`'s. Those that start with
|
The settings that start with `SWWAF_` are `servewaf`'s. Those that start with
|
||||||
`SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SWW_COMMAND` chooses
|
`SWRP_` are `rproxy`'s, and are under "`rproxy`" below. `SMALLWEBWAF_COMMAND`
|
||||||
between the two (see "The image's command" below).
|
chooses between the two (see "The image's command" below).
|
||||||
|
|
||||||
## Forms of values
|
## Forms of values
|
||||||
|
|
||||||
@@ -28,336 +28,270 @@ between the two (see "The image's command" below).
|
|||||||
|
|
||||||
## Core
|
## Core
|
||||||
|
|
||||||
- `SWW_LISTEN_ADDR` (default `:8080`): where `discriminator` listens: `:` and a
|
- `SWWAF_LISTEN_ADDR` (default `:8080`): where `servewaf` listens: `:` and a
|
||||||
port, such as `:9000`. Leave the host out (see "Ports" in
|
port, such as `:9000`. Leave the host out (see "Ports" in
|
||||||
[`SPEC.md`](../SPEC.md)).
|
[`SPEC.md`](../SPEC.md)).
|
||||||
- `SWW_INSTANCE_NAME` (default: the container's host name): the `instance` name
|
- `SWWAF_INSTANCE_NAME` (default: the container's host name): the `instance`
|
||||||
in each line `discriminator` logs, and in each alert and metric, such as
|
name in each line `servewaf` logs, and in each alert and metric, such as
|
||||||
`fsn1/discriminator`. Valid UTF-8. No control character while
|
`fsn1/servewaf`. Valid UTF-8. No control character while
|
||||||
`SWW_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a
|
`SWWAF_ALERT_NTFY_URL` is set. 1 to 48 printable ASCII characters without a
|
||||||
space while `SWW_LOG_REMOTE_URL` is set and `SWW_LOG_REMOTE_APP_NAME` is not.
|
space while `SWWAF_LOG_REMOTE_URL` is set and `SWWAF_LOG_REMOTE_APP_NAME` is
|
||||||
- `SWW_MODE` (default `enforce`): `enforce` or `observe`.
|
not.
|
||||||
- `SWW_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6 client's
|
- `SWWAF_MODE` (default `enforce`): `enforce` or `observe`.
|
||||||
group, from 32 to 128.
|
- `SWWAF_IPV6_GROUP_PREFIX` (default `64`): the prefix length of an IPv6
|
||||||
- `SWW_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
|
client's group, from 32 to 128.
|
||||||
|
- `SWWAF_MAX_TRACKED_CLIENTS` (default `20000`): the most clients kept, a whole
|
||||||
number above zero.
|
number above zero.
|
||||||
- `SWW_MAX_BANS` (default `5000`): how many of its own bans `discriminator`
|
- `SWWAF_MAX_BANS` (default `5000`): how many of its own bans `servewaf` keeps,
|
||||||
keeps, a whole number above zero.
|
a whole number above zero.
|
||||||
|
|
||||||
## Tokens
|
## Tokens
|
||||||
|
|
||||||
- `SWW_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
|
- `SWWAF_ADMIN_TOKEN` (default unset): the token for the ban endpoints and
|
||||||
`/_sww/clients/<ip>`. At least 32 characters.
|
`/_smallwebwaf/clients/<ip>`. At least 32 characters.
|
||||||
- `SWW_METRICS_TOKEN` (default unset): the token for `/_sww/metrics`. At least
|
- `SWWAF_METRICS_TOKEN` (default unset): the token for `/_smallwebwaf/metrics`.
|
||||||
32 characters.
|
At least 32 characters.
|
||||||
|
|
||||||
## State files
|
## State files
|
||||||
|
|
||||||
- `SWW_STATE_DIR` (default `/var/lib/sww`): the directory of the state files, an
|
- `SWWAF_STATE_DIR` (default `/var/lib/smallwebwaf`): the directory of the state
|
||||||
absolute path that exists and can be written.
|
files, an absolute path that exists and can be written.
|
||||||
- `SWW_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
- `SWWAF_STATE_WRITE_DELAY` (default `10s`): how long `bans.json` waits to be
|
||||||
written after a ban is made, lifted or made permanent. A duration.
|
written after a ban is made, lifted or made permanent. A duration.
|
||||||
- `SWW_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
|
- `SWWAF_STATE_COUNTER_INTERVAL` (default `15m`): how often every state file is
|
||||||
written. A duration.
|
written. A duration.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
- `SWW_LOG_LEVEL` (default `info`): the least severe of `discriminator`'s own
|
- `SWWAF_LOG_LEVEL` (default `info`): the least severe of `servewaf`'s own
|
||||||
messages that are written: `debug`, `info`, `warn` or `error`.
|
messages that are written: `debug`, `info`, `warn` or `error`.
|
||||||
- `SWW_LOG_REQUEST_HEADERS` (default
|
- `SWWAF_LOG_REQUEST_HEADERS` (default
|
||||||
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
`accept,accept-language,accept-encoding,content-type,origin,range`): the
|
||||||
request headers recorded in the log line, in either case. `Host` and
|
request headers recorded in the log line, in either case. `Host` and
|
||||||
`Transfer-Encoding` are refused; the host is the field `host`.
|
`Transfer-Encoding` are refused; the host is the field `host`.
|
||||||
- `SWW_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
|
- `SWWAF_LOG_REMOTE_URL` (default unset): a syslog server that also gets every
|
||||||
line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`,
|
line written to stdout: `syslog+udp://`, `syslog+tcp://` or `syslog+tls://`,
|
||||||
each with a host and a port and nothing more.
|
each with a host and a port and nothing more.
|
||||||
- `SWW_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates that
|
- `SWWAF_LOG_REMOTE_TLS_CA_FILE` (default unset): a file of PEM certificates
|
||||||
a `syslog+tls` server's certificate must chain to, instead of the host's own.
|
that a `syslog+tls` server's certificate must chain to, instead of the host's
|
||||||
A file that cannot be read, or holds no certificate, is refused.
|
own. A file that cannot be read, or holds no certificate, is refused.
|
||||||
- `SWW_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they wait
|
- `SWWAF_LOG_REMOTE_BUFFER` (default `10000`): the most lines held while they
|
||||||
to be sent, a whole number above zero.
|
wait to be sent, a whole number above zero.
|
||||||
- `SWW_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
- `SWWAF_LOG_REMOTE_FACILITY` (default `local0`): the syslog facility: `kern`,
|
||||||
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
|
`user`, `mail`, `daemon`, `auth`, `syslog`, `lpr`, `news`, `uucp`, `cron`,
|
||||||
`authpriv`, `ftp`, or `local0` to `local7`.
|
`authpriv`, `ftp`, or `local0` to `local7`.
|
||||||
- `SWW_LOG_REMOTE_APP_NAME` (default: `SWW_INSTANCE_NAME`): the app name in each
|
- `SWWAF_LOG_REMOTE_APP_NAME` (default: `SWWAF_INSTANCE_NAME`): the app name in
|
||||||
syslog record, 1 to 48 printable ASCII characters without a space.
|
each syslog record, 1 to 48 printable ASCII characters without a space.
|
||||||
|
|
||||||
## Metrics
|
## Metrics
|
||||||
|
|
||||||
- `SWW_METRICS_TOP_N` (default `50`): how many AS numbers and how many countries
|
- `SWWAF_METRICS_TOP_N` (default `50`): how many AS numbers and how many
|
||||||
get series of their own. A whole number above zero.
|
countries get series of their own. A whole number above zero.
|
||||||
|
|
||||||
## Static lists
|
## Static lists
|
||||||
|
|
||||||
- `SWW_ALLOW_NETS` (default empty): netblocks you trust, such as your monitoring
|
- `SWWAF_ALLOW_NETS` (default empty): netblocks you trust, such as your
|
||||||
(see step 4 in [`SPEC.md`](../SPEC.md)).
|
monitoring (see step 3 in [`SPEC.md`](../SPEC.md)).
|
||||||
- `SWW_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
- `SWWAF_RATE_LIMIT_EXEMPT_NETS` (default empty): netblocks whose clients are
|
||||||
exempt from rate limiting and the byte limits.
|
exempt from rate limiting and the byte limits.
|
||||||
- `SWW_DENY_NETS` (default empty): netblocks whose clients are refused (see step
|
- `SWWAF_DENY_NETS` (default empty): netblocks whose clients are refused (see
|
||||||
4 in [`SPEC.md`](../SPEC.md)).
|
step 3 in [`SPEC.md`](../SPEC.md)).
|
||||||
|
|
||||||
## Request rate limits, per client
|
## Request rate limits, per client
|
||||||
|
|
||||||
The request past one of these limits is refused, and the client is banned.
|
- `SWWAF_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
|
||||||
|
|
||||||
- `SWW_RATE_LIMIT_PER_MINUTE` (default `120`, 2 a second): the most requests a
|
|
||||||
client may make in a minute. A count or `off`. The default leaves room for a
|
client may make in a minute. A count or `off`. The default leaves room for a
|
||||||
person opening two pages at once with an empty cache, up to 60 files each.
|
person opening two pages at once with an empty cache, up to 60 files each.
|
||||||
- `SWW_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
|
- `SWWAF_RATE_LIMIT_PER_HOUR` (default `1500`): the most requests a client may
|
||||||
make in an hour. A count or `off`. It is the main limit against scrapers. A
|
make in an hour. A count or `off`. It is the main limit against scrapers. A
|
||||||
person reading a site for an hour, a page every 20 seconds, makes about 900. A
|
person reading a site for an hour, a page every 20 seconds, makes about 900. A
|
||||||
scraper making one request every 2 seconds makes 1,800 an hour, more than the
|
scraper making one request every 2 seconds makes 1,800 an hour, more than the
|
||||||
limit.
|
limit.
|
||||||
- `SWW_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may make
|
- `SWWAF_RATE_LIMIT_PER_DAY` (default `6000`): the most requests a client may
|
||||||
in a day. A count or `off`. The default is four hours at the hour limit. A
|
make in a day. A count or `off`. The default is four hours at the hour limit.
|
||||||
scraper making one request every 10 seconds makes 8,640 a day, more than the
|
A scraper making one request every 10 seconds makes 8,640 a day, more than the
|
||||||
limit.
|
limit.
|
||||||
- A CI runner asks its code forge for work all day and can pass the limits. Put
|
- A CI runner asks its code forge for work all day and can pass the limits. Put
|
||||||
the runners' addresses in `SWW_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
|
the runners' addresses in `SWWAF_RATE_LIMIT_EXEMPT_NETS`. Do the same for a
|
||||||
network where many people share one address, such as an office.
|
network where many people share one address, such as an office.
|
||||||
- Counting cannot catch a client that stays under all three limits (see "Request
|
- Counting cannot catch a client that stays under all three limits (see "Request
|
||||||
rate limits, per client" in [`SPEC.md`](../SPEC.md)).
|
rate limits, per client" in [`SPEC.md`](../SPEC.md)).
|
||||||
- `SWW_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from rate
|
- `SWWAF_RATE_LIMIT_EXEMPT_PATHS` (default empty): path prefixes exempt from
|
||||||
limiting and the byte limits, such as `/assets/`.
|
rate limiting and the byte limits, such as `/assets/`.
|
||||||
|
|
||||||
## Byte limits, per client
|
## Byte limits, per client
|
||||||
|
|
||||||
These count body bytes, never headers. What the visitor sent over a WebSocket
|
- `SWWAF_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWWAF_BYTES_LIMIT_PER_HOUR`
|
||||||
counts as request bytes, and what the app sent as response bytes. The bytes
|
(default `50G`) and `SWWAF_BYTES_LIMIT_PER_DAY` (default `100G`): the most
|
||||||
counted for a request and its response are added to the client's total once the
|
bytes counted for a client in each window. A size or `off`.
|
||||||
response has ended. The request that takes a client's total past a limit is not
|
- `SWWAF_BYTES_COUNT` (default `both`): which body bytes the byte limits count:
|
||||||
cut off; the client is banned.
|
`response`, `request` or `both`.
|
||||||
|
|
||||||
- `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR`
|
|
||||||
(default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes
|
|
||||||
counted for a client in each window, a size or `off`. `SWW_BYTES_COUNT` says
|
|
||||||
which bytes count: response bodies, request bodies or both. The request that
|
|
||||||
passes the limit is not cut off, and the client is banned when its response
|
|
||||||
ends.
|
|
||||||
- `SWW_BYTES_COUNT` (default `both`): what the byte limits and the byte anomaly
|
|
||||||
thresholds count: `response`, the app's response bodies; `request`, the
|
|
||||||
visitors' request bodies; or `both`. What the visitor sent over a WebSocket
|
|
||||||
counts as request bytes, and what the app sent as response bytes.
|
|
||||||
|
|
||||||
## Lookup of AS number and country
|
## Lookup of AS number and country
|
||||||
|
|
||||||
- `SWW_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
|
- `SWWAF_LOOKUP_SOURCE` (default `geojs`): `geojs`, `file` or `off`. `off` stops
|
||||||
the start while a setting that needs a lookup is set:
|
the start while a setting that needs a lookup is set:
|
||||||
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES`;
|
- the two country lists;
|
||||||
- `SWW_ADD_LOOKUP_HEADERS`;
|
- `SWWAF_ADD_LOOKUP_HEADERS`;
|
||||||
- `SWW_TAG_DENY` or `SWW_TAG_LIMIT_PERCENT` naming a `country=` tag, or an
|
- the four biased threshold lists and `SWWAF_ASN_LIMIT_PERCENT_URL`;
|
||||||
`asn=` tag while `SWW_ROUTEWATCH_URL` is unset;
|
- `SWWAF_UNKNOWN_LIMIT_PERCENT` below `100`;
|
||||||
- the two byte percentage lists and `SWW_ASN_LIMIT_PERCENT_URL`;
|
|
||||||
- `SWW_UNKNOWN_LIMIT_PERCENT` below `100`;
|
|
||||||
- an anomaly threshold per AS number.
|
- an anomaly threshold per AS number.
|
||||||
- `SWW_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
|
- `SWWAF_LOOKUP_DB_PATH` (default empty): the IPinfo Lite database file, in its
|
||||||
`.mmdb` form, for `SWW_LOOKUP_SOURCE=file`.
|
`.mmdb` form, for `SWWAF_LOOKUP_SOURCE=file`.
|
||||||
- `SWW_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
|
- `SWWAF_LOOKUP_TIMEOUT` (default `800ms`): how long a request waits for its
|
||||||
client's first answer from GeoJS, and how long a request to GeoJS may take. A
|
client's first answer from GeoJS, and how long a request to GeoJS may take. A
|
||||||
duration; never `off`.
|
duration; never `off`.
|
||||||
- `SWW_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app
|
- `SWWAF_ADD_LOOKUP_HEADERS` (default `false`): `true` has `rproxy` pass the app
|
||||||
the client's AS number in `X-Client-ASN` and its country in
|
the client's AS number in `X-Client-ASN` and its country in
|
||||||
`X-Client-Country`.
|
`X-Client-Country`.
|
||||||
|
|
||||||
## Country list
|
## Country lists
|
||||||
|
|
||||||
- `SWW_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
- `SWWAF_DENIED_COUNTRIES` (default empty): countries whose clients are refused,
|
||||||
countries whose clients get through, such as `us,de`. A country whose
|
such as `cn,ru,kp`.
|
||||||
`country=` tag is in `SWW_TAG_DENY` stops the start.
|
- `SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES` (default empty): when set, the only
|
||||||
|
countries whose clients get through, such as `us,de`. A country on both lists
|
||||||
## Request tags
|
stops the start.
|
||||||
|
|
||||||
Every request gets tags from what `discriminator` knows of its client. See
|
|
||||||
"Request tags" in [`SPEC.md`](../SPEC.md).
|
|
||||||
|
|
||||||
- `SWW_TAG_LISTS` (default empty): address lists that each add a tag, as
|
|
||||||
`name=URL` pairs, such as `torexit=https://example.com/tor.txt`. A name is the
|
|
||||||
tag, and may hold `=`, such as `vpn=mullvad`. Each list is an `http` or
|
|
||||||
`https` URL fetched like a blocklist. A name or URL listed twice is refused.
|
|
||||||
So is a URL that is also in `SWW_BLOCKLIST_URLS`, `SWW_ASN_LIMIT_PERCENT_URL`,
|
|
||||||
or the CrowdSec decision list.
|
|
||||||
- `SWW_TAG_DENY` (default empty): tags whose requests are refused, such as
|
|
||||||
`torexit,country=CN`. A tag listed twice, an `asn=` tag without a number, or a
|
|
||||||
`country=` tag without a country, is refused.
|
|
||||||
- `SWW_TAG_LIMIT_PERCENT` (default empty): tags that lower a client's limits, as
|
|
||||||
tag and percentage pairs, such as `vpn:50,asn=14061:25`. A percentage is from
|
|
||||||
`1` to `99`, of every rate limit and byte limit. The lowest percentage among a
|
|
||||||
client's tags applies. A tag listed twice is refused. So is
|
|
||||||
`exploitsignature`: a request gets that tag after the rate limits have counted
|
|
||||||
it.
|
|
||||||
|
|
||||||
- `SWW_ROUTEWATCH_URL` (default unset): the `http` or `https` URL of a
|
|
||||||
routewatch instance. [routewatch](https://git.eeqj.de/sneak/routewatch) is a
|
|
||||||
service that gives an address's announced netblock and origin AS.
|
|
||||||
`discriminator` asks it about each client, in the background, and adds the
|
|
||||||
tags `asn=<number>` and `prefix=<netblock>` from its answer. While it is
|
|
||||||
unset, nothing asks routewatch and no request gets a `prefix=` tag.
|
|
||||||
|
|
||||||
`SWW_TAG_DENY` and `SWW_TAG_LIMIT_PERCENT` accept tags in either case. A tag
|
|
||||||
that no request can get stops the start. A request can get only these:
|
|
||||||
|
|
||||||
- a name in `SWW_TAG_LISTS`;
|
|
||||||
- an `asn=` or `country=` tag;
|
|
||||||
- a `prefix=` tag with a netblock, while `SWW_ROUTEWATCH_URL` is set. It matches
|
|
||||||
only that exact announced netblock, such as `prefix=192.0.2.0/24`;
|
|
||||||
- a `dnsbl=` tag of a zone in `SWW_DNSBL_ZONES`, in lower case, with a key
|
|
||||||
masked as the `reputation` field shows it;
|
|
||||||
- `abuseipdb`, while `SWW_ABUSEIPDB_KEY` is set;
|
|
||||||
- `exploitsignature`, in `SWW_TAG_DENY` only, while `SWW_RULES_ENABLED` is
|
|
||||||
`true`, or while neither `SWW_WAF_MODE` nor `SWW_WAF_ANOMALY_THRESHOLD` is
|
|
||||||
`off`.
|
|
||||||
|
|
||||||
`crowdsec` stops the start in both settings, because a CrowdSec decision refuses
|
|
||||||
the request first.
|
|
||||||
|
|
||||||
## Biased thresholds
|
## Biased thresholds
|
||||||
|
|
||||||
- `SWW_ASN_BYTES_PERCENT` and `SWW_COUNTRY_BYTES_PERCENT` (default empty): a
|
- `SWWAF_ASN_LIMIT_PERCENT` (default empty): a list of AS numbers, each with `:`
|
||||||
percentage of the byte limits alone, for the AS numbers or countries listed,
|
and a percentage, such as `AS14061:50,AS16276:50,AS45102:25`. An AS number
|
||||||
such as `AS14061:50` or `cn:25`. It takes the place of the percentage
|
listed twice is refused.
|
||||||
`SWW_TAG_LIMIT_PERCENT` gives their `asn=` or `country=` tag. An AS number or
|
- `SWWAF_COUNTRY_LIMIT_PERCENT` (default empty): the same by country, such as
|
||||||
country listed twice is refused.
|
`cn:25,ru:50`.
|
||||||
- `SWW_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
|
- `SWWAF_ASN_BYTES_PERCENT` and `SWWAF_COUNTRY_BYTES_PERCENT` (default empty):
|
||||||
|
the same, for the byte limits alone.
|
||||||
|
- `SWWAF_UNKNOWN_LIMIT_PERCENT` (default `100`): the percentage for a client the
|
||||||
lookup cannot place.
|
lookup cannot place.
|
||||||
- `SWW_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
- `SWWAF_ASN_LIMIT_PERCENT_URL` (default unset): the `http` or `https` URL of a
|
||||||
text file with one AS number and percentage on each line, such as
|
text file with one AS number and percentage on each line, such as
|
||||||
`AS64496:50`. Each entry counts like an `asn=` tag in `SWW_TAG_LIMIT_PERCENT`.
|
`AS64496:50`. A URL that is also in `SWWAF_BLOCKLIST_URLS` is refused.
|
||||||
A URL that is also in `SWW_BLOCKLIST_URLS` is refused.
|
|
||||||
|
|
||||||
## Attack detection
|
## Attack detection
|
||||||
|
|
||||||
- `SWW_RULES_DIR` (default `/etc/sww/rules.d`): the directory of the rule files.
|
- `SWWAF_RULES_DIR` (default `/etc/smallwebwaf/rules.d`): the directory of the
|
||||||
- `SWW_RULES_ENABLED` (default `true`): `false` reads no rule file.
|
rule files.
|
||||||
- `SWW_TRAP_PATHS` (default empty): paths only scanners ask for, such as
|
- `SWWAF_RULES_ENABLED` (default `true`): `false` reads no rule file.
|
||||||
|
- `SWWAF_TRAP_PATHS` (default empty): paths only scanners ask for, such as
|
||||||
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
|
`/wp-login.php,/xmlrpc.php`. Each starts with `/` and holds no `?`.
|
||||||
- `SWW_WAF_MODE` (default `block`): `off`, `detect` or `block`.
|
- `SWWAF_WAF_MODE` (default `block`): `off`, `detect` or `block`.
|
||||||
- `SWW_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level,
|
- `SWWAF_WAF_PARANOIA_LEVEL` (default `1`): the Core Rule Set's paranoia level,
|
||||||
from 1 to 4.
|
from 1 to 4.
|
||||||
- `SWW_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a
|
- `SWWAF_WAF_ANOMALY_THRESHOLD` (default `5`): the score at which a request is a
|
||||||
match. A whole number above zero, or `off`.
|
match. A whole number above zero, or `off`.
|
||||||
- `SWW_WAF_DISABLED_RULES` (default
|
- `SWWAF_WAF_DISABLED_RULES` (default
|
||||||
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
|
`920340,920420,920440,920640,930130,930140`): the ids of Core Rule Set rules
|
||||||
to switch off. Each a whole number above zero. An id from 900000 to 900999 is
|
to switch off. Each a whole number above zero. An id from 900000 to 900999 is
|
||||||
refused: those are the rules that set the Core Rule Set up, and
|
refused: those are the rules that set the Core Rule Set up, and
|
||||||
`smallwebwaf`'s own.
|
`smallwebwaf`'s own.
|
||||||
- `SWW_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does
|
- `SWWAF_WAF_EXEMPT_PATHS` (default empty): path prefixes the Core Rule Set does
|
||||||
not inspect.
|
not inspect.
|
||||||
- `SWW_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body the
|
- `SWWAF_WAF_BODY_LIMIT` (default `off`): `off`, or how much of a request body
|
||||||
Core Rule Set reads, a size of at most `64K`. A question carries no more of a
|
the Core Rule Set reads, a size of at most `64K`. A question carries no more
|
||||||
body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one byte of
|
of a body than 64 KiB, and the Core Rule Set reads at most 64 KiB less one
|
||||||
it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
|
byte of it, whatever the value (see "The answer" in [`SPEC.md`](../SPEC.md)).
|
||||||
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` (default unset): a file of Core Rule Set
|
- `SWWAF_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client
|
||||||
exclusions, written as Coraza directives. It is loaded before the Core Rule
|
in a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
|
||||||
Set's rules, so it holds only `SecAction` and `SecRule` lines that exclude
|
|
||||||
with `ctl:ruleRemoveTargetById` or `ctl:ruleRemoveById`. A file that cannot be
|
|
||||||
read or does not parse stops the start, whatever `SWW_WAF_MODE` is. So does a
|
|
||||||
file that names a number from 900000 to 900999 outside a comment, alone or in
|
|
||||||
a range. So does a directive that removes or changes rules as the file loads,
|
|
||||||
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
|
|
||||||
yet. It has no `_FILE` form.
|
|
||||||
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may get
|
|
||||||
in a minute from a rule file, a trap path or the Core Rule Set, a count or
|
|
||||||
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
|
|
||||||
|
|
||||||
## Ban lengths, response and scope
|
## Ban lengths, response and scope
|
||||||
|
|
||||||
- `SWW_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
|
- `SWWAF_BAN_RESPONSE` (default `403`): `403`, `429` or `close`.
|
||||||
- `SWW_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A
|
- `SWWAF_LIMIT_BAN_DURATION` (default `1h`): the ban for a first broken limit. A
|
||||||
duration.
|
duration.
|
||||||
- `SWW_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
|
- `SWWAF_LIMIT_BAN_REPEAT_WINDOW` (default `24h`): the time after a ban ends
|
||||||
within which breaking a limit again lengthens the next ban. A duration.
|
within which breaking a limit again lengthens the next ban. A duration.
|
||||||
- `SWW_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit; one
|
- `SWWAF_MAX_BAN_DURATION` (default `7d`): the longest ban for a broken limit;
|
||||||
that would be longer is permanent. A duration.
|
one that would be longer is permanent. A duration.
|
||||||
- `SWW_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
- `SWWAF_ATTACK_BAN_DURATION` (default `7d`): the ban for a first clear sign of
|
||||||
attack. A duration.
|
attack. A duration.
|
||||||
- `SWW_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
- `SWWAF_BAN_SCOPE_V4_PREFIX` (default `32`): how much a ban on an IPv4 client
|
||||||
covers: the prefix length of its netblock, from 0 to 32.
|
covers: the prefix length of its netblock, from 0 to 32.
|
||||||
|
|
||||||
## Reputation
|
## Reputation
|
||||||
|
|
||||||
- `SWW_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
|
- `SWWAF_BLOCKLIST_URLS` (default empty): blocklists to fetch, such as
|
||||||
`https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL
|
`https://www.spamhaus.org/drop/drop.txt`. Each an `http` or `https` URL
|
||||||
without a user or a fragment. A URL listed twice is refused.
|
without a user or a fragment. A URL listed twice is refused.
|
||||||
- `SWW_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its last
|
- `SWWAF_BLOCKLIST_REFRESH` (default `24h`): how long a list waits after its
|
||||||
try before it is fetched again. A duration of `1h` or more.
|
last try before it is fetched again. A duration of `1h` or more.
|
||||||
- `SWW_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`.
|
- `SWWAF_BLOCKLIST_ACTION` (default `deny`): `deny`, `limit:<percent>` or `log`.
|
||||||
- `SWW_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as
|
- `SWWAF_DNSBL_ZONES` (default empty): the DNS blocklists to ask, such as
|
||||||
`dnsbl.dronebl.org`. Each a DNS name of at most 189 characters. A Spamhaus
|
`dnsbl.dronebl.org`. Each a DNS name of at most 189 characters. A Spamhaus
|
||||||
zone carries its key, as in `<key>.xbl.dq.spamhaus.net`. A zone listed twice
|
zone carries its key, as in `<key>.xbl.dq.spamhaus.net`. A zone listed twice
|
||||||
is refused, whatever its case or key.
|
is refused, whatever its case or key.
|
||||||
- `SWW_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through,
|
- `SWWAF_DNSBL_RESOLVER` (default unset): the resolver to ask the zones through,
|
||||||
such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional
|
such as `192.0.2.53` or `[2001:db8::53]:5353`. An IP address with an optional
|
||||||
port, 53 when none is given. Unset, the host's resolver is used.
|
port, 53 when none is given. Unset, the host's resolver is used.
|
||||||
- `SWW_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
|
- `SWWAF_ABUSEIPDB_KEY` (default unset): the key of your AbuseIPDB account. A
|
||||||
control character in it is refused.
|
control character in it is refused.
|
||||||
- `SWW_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
|
- `SWWAF_ABUSEIPDB_MIN_SCORE` (default `75`): the least abuse confidence score,
|
||||||
from 0 to 100, that is a hit.
|
from 0 to 100, that is a hit.
|
||||||
- `SWW_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day, a
|
- `SWWAF_ABUSEIPDB_DAILY_BUDGET` (default `900`): the most checks made in a day,
|
||||||
whole number above zero. AbuseIPDB's free accounts may make 1,000.
|
a whole number above zero. AbuseIPDB's free accounts may make 1,000.
|
||||||
- `SWW_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine
|
- `SWWAF_CROWDSEC_LAPI_URL` (default unset): the local API of a CrowdSec engine
|
||||||
you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a
|
you run, such as `http://172.17.0.1:8080`. An `http` or `https` URL without a
|
||||||
user or a fragment; its decision list is at `v1/decisions` under it. Set
|
user or a fragment; its decision list is at `v1/decisions` under it. Set
|
||||||
without the key, it is refused. So is a decision list URL that is also in
|
without the key, it is refused. So is a decision list URL that is also in
|
||||||
`SWW_BLOCKLIST_URLS`, or that is `SWW_ASN_LIMIT_PERCENT_URL`.
|
`SWWAF_BLOCKLIST_URLS`, or that is `SWWAF_ASN_LIMIT_PERCENT_URL`.
|
||||||
- `SWW_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
- `SWWAF_CROWDSEC_LAPI_KEY` (default unset): the key the engine gave for
|
||||||
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
|
`smallwebwaf`, as `cscli bouncers add smallwebwaf` prints it. Set without the
|
||||||
URL, it is refused.
|
URL, it is refused.
|
||||||
- `SWW_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
|
- `SWWAF_REPUTATION_ACTION` (default `limit:25`): `deny`, `limit:<percent>` or
|
||||||
`log`, for DNS blocklist and AbuseIPDB hits.
|
`log`, for DNS blocklist and AbuseIPDB hits.
|
||||||
- `SWW_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
|
- `SWWAF_REPUTATION_CACHE_TTL` (default `24h`): how long a zone's verdict or
|
||||||
AbuseIPDB's score is used. A duration.
|
AbuseIPDB's score is used. A duration.
|
||||||
- `SWW_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a check
|
- `SWWAF_REPUTATION_TIMEOUT` (default `2s`): how long a query to a zone or a
|
||||||
with AbuseIPDB may take. A duration.
|
check with AbuseIPDB may take. A duration.
|
||||||
|
|
||||||
## Alerting
|
## Alerting
|
||||||
|
|
||||||
- `SWW_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each alert
|
- `SWWAF_ALERT_WEBHOOK_URL` (default unset): the webhook that receives each
|
||||||
as a JSON object. An `http` or `https` URL without a user or a fragment.
|
alert as a JSON object. An `http` or `https` URL without a user or a fragment.
|
||||||
- `SWW_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post, such
|
- `SWWAF_ALERT_WEBHOOK_HEADERS` (default empty): headers sent with each post,
|
||||||
as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and value. A
|
such as `Authorization:Bearer 0123456789abcdef`. A list of name, `:` and
|
||||||
value cannot hold a comma.
|
value. A value cannot hold a comma.
|
||||||
- `SWW_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook that
|
- `SWWAF_ALERT_SLACK_WEBHOOK_URL` (default unset): the Slack incoming webhook
|
||||||
receives each alert as a message, checked as the webhook URL is.
|
that receives each alert as a message, checked as the webhook URL is.
|
||||||
- `SWW_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each alert,
|
- `SWWAF_ALERT_NTFY_URL` (default unset): the ntfy topic that receives each
|
||||||
as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked as the
|
alert, as the topic's full URL, such as `https://ntfy.sh/my-alerts`, checked
|
||||||
webhook URL is.
|
as the webhook URL is.
|
||||||
- `SWW_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
|
- `SWWAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token. A control
|
||||||
character in it is refused.
|
character in it is refused.
|
||||||
- `SWW_ALERT_EVENTS` (default
|
- `SWWAF_ALERT_EVENTS` (default
|
||||||
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
|
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
|
||||||
the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
|
the event types alerts are sent for, from those in [`alerts.md`](alerts.md)
|
||||||
but `summary`. Any other name is refused.
|
but `summary`. Any other name is refused.
|
||||||
- `SWW_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
|
- `SWWAF_ALERT_COOLDOWN` (default `15m`): how long after an alert is sent its
|
||||||
repeats are held back. A duration or `off`.
|
repeats are held back. A duration or `off`.
|
||||||
- `SWW_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A
|
- `SWWAF_ALERT_MAX_PER_HOUR` (default `60`): the most alerts sent in an hour. A
|
||||||
whole number above zero, or `off`.
|
whole number above zero, or `off`.
|
||||||
|
|
||||||
## Anomaly thresholds
|
## Anomaly thresholds
|
||||||
|
|
||||||
Each scope has four thresholds: the most requests and the most body bytes
|
Each scope has four thresholds: the most requests and the most bytes counted in
|
||||||
counted in a minute and in an hour. The bytes are those `SWW_BYTES_COUNT` names.
|
a minute and in an hour. Requests are counts, bytes are sizes. Each is `off` by
|
||||||
Requests are counts, bytes are sizes. Each is `off` by default. The four
|
default. The four thresholds per client are:
|
||||||
thresholds per client are:
|
|
||||||
|
|
||||||
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
|
- `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_MINUTE`;
|
||||||
- `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
|
- `SWWAF_ANOMALY_CLIENT_REQUESTS_PER_HOUR`;
|
||||||
- `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
|
- `SWWAF_ANOMALY_CLIENT_BYTES_PER_MINUTE`;
|
||||||
- `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR`.
|
- `SWWAF_ANOMALY_CLIENT_BYTES_PER_HOUR`.
|
||||||
|
|
||||||
The other scopes' names have another prefix before the same four endings, such
|
The other scopes' names have another prefix before the same four endings, such
|
||||||
as `REQUESTS_PER_MINUTE`:
|
as `REQUESTS_PER_MINUTE`:
|
||||||
|
|
||||||
- `SWW_ANOMALY_NET_...`: per netblock around a client. Two settings give the
|
- `SWWAF_ANOMALY_NET_...`: per netblock around a client. Two settings give the
|
||||||
netblock's length:
|
netblock's length:
|
||||||
- `SWW_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
|
- `SWWAF_ANOMALY_NET_V4_PREFIX` (default `24`, from 0 to 32) for an IPv4
|
||||||
client;
|
client;
|
||||||
- `SWW_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
|
- `SWWAF_ANOMALY_NET_V6_PREFIX` (default `48`, from 0 to 128) for an IPv6
|
||||||
client.
|
client.
|
||||||
- `SWW_ANOMALY_ASN_...`: per AS number.
|
- `SWWAF_ANOMALY_ASN_...`: per AS number.
|
||||||
- `SWW_ANOMALY_TOTAL_...`: the whole service.
|
- `SWWAF_ANOMALY_TOTAL_...`: the whole service.
|
||||||
- `SWW_WATCH_...`: each netblock in `SWW_WATCH_NETS`, as a whole.
|
- `SWWAF_WATCH_...`: each netblock in `SWWAF_WATCH_NETS`, as a whole.
|
||||||
`SWW_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such as
|
`SWWAF_WATCH_NETS` (default empty) is a list of name, `=` and netblock, such
|
||||||
`office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
|
as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. An item without `=` or a
|
||||||
name, or with a bad netblock, is refused. So is a name used twice.
|
name, or with a bad netblock, is refused. So is a name used twice.
|
||||||
|
|
||||||
## Settings given as files
|
## Settings given as files
|
||||||
@@ -376,9 +310,9 @@ Then mount its directory into the container read-only, and name the file:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run \
|
docker run \
|
||||||
-v /srv/app/tokens:/etc/sww/tokens:ro \
|
-v /srv/app/tokens:/etc/smallwebwaf/tokens:ro \
|
||||||
-e SWW_COMMAND=discriminator \
|
-e SMALLWEBWAF_COMMAND=servewaf \
|
||||||
-e SWW_METRICS_TOKEN_FILE=/etc/sww/tokens/metrics \
|
-e SWWAF_METRICS_TOKEN_FILE=/etc/smallwebwaf/tokens/metrics \
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -391,42 +325,41 @@ A few limits are fixed rather than settings:
|
|||||||
- at most 1,000 DNS blocklist queries are under way at once;
|
- at most 1,000 DNS blocklist queries are under way at once;
|
||||||
- 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;
|
||||||
- `discriminator` holds at most 10,000 answered requests awaiting their reports;
|
- `servewaf` holds at most 10,000 answered requests awaiting their reports;
|
||||||
- `discriminator` refuses a request with more than 50 cookies or more than 100
|
- `servewaf` refuses a request with more than 50 cookies or more than 100 header
|
||||||
header fields (see "Answering in time" under "`discriminator`" in
|
fields (see "Answering in time" under "`servewaf`" in
|
||||||
[`SPEC.md`](../SPEC.md));
|
[`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 `discriminator`'s answer, and
|
- `rproxy` reads at most 1 MiB of the body of `servewaf`'s answer, and answers
|
||||||
answers `502` to a longer one.
|
`502` to a longer one.
|
||||||
|
|
||||||
## `discriminator`'s answer and `rproxy`'s wait
|
## `servewaf`'s answer and `rproxy`'s wait
|
||||||
|
|
||||||
`SWW_LOOKUP_TIMEOUT` is the longest a question waits on an outside source. Keep
|
`SWWAF_LOOKUP_TIMEOUT` is the longest a question waits on an outside source.
|
||||||
it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question waits
|
Keep it well under `rproxy`'s `SWRP_TIMEOUT_MS`, as the default is. No question
|
||||||
for `SWW_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are asked
|
waits for `SWWAF_REPUTATION_TIMEOUT`, since the DNS blocklists and AbuseIPDB are
|
||||||
in the background (see "Answering in time" under "`discriminator`" in
|
asked in the background (see "Answering in time" under "`servewaf`" in
|
||||||
[`SPEC.md`](../SPEC.md)).
|
[`SPEC.md`](../SPEC.md)).
|
||||||
|
|
||||||
## `rproxy`
|
## `rproxy`
|
||||||
|
|
||||||
`sww rproxy` reads the settings below and no others. They are read once at
|
`smallwebwaf rproxy` reads the settings below and no others. They are read once
|
||||||
start, and an invalid value stops the start. None can be given as a file, since
|
at start, and an invalid value stops the start. None can be given as a file,
|
||||||
`rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says what
|
since `rproxy` never touches disk. "`rproxy`" in [`SPEC.md`](../SPEC.md) says
|
||||||
they do.
|
what they do.
|
||||||
|
|
||||||
A time here is a whole number of milliseconds above zero, such as `2000`.
|
A time here is a whole number of milliseconds above zero, such as `2000`.
|
||||||
|
|
||||||
- `SWRP_WAF_BACKEND` (required, no default): `discriminator`'s URL. An `http` or
|
- `SWRP_WAF_BACKEND` (required, no default): `servewaf`'s URL. An `http` or
|
||||||
`https` URL with a host and an optional port, and nothing more. `rproxy`
|
`https` URL with a host and an optional port, and nothing more. `rproxy`
|
||||||
speaks HTTP/1.1 to it.
|
speaks HTTP/1.1 to it.
|
||||||
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for
|
- `SWRP_TIMEOUT_MS` (default `2000`): how long `rproxy` waits for `servewaf`'s
|
||||||
`discriminator`'s answer, to a question and to a report. A time; never `off`.
|
answer, to a question and to a report. A time; never `off`.
|
||||||
- `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request when
|
- `SWRP_DEFAULT_DISPOSITION` (default `allow`): what is done with a request when
|
||||||
`discriminator` cannot be connected to, does not answer at all, or does not
|
`servewaf` cannot be connected to, does not answer at all, or does not answer
|
||||||
answer within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny`
|
within `SWRP_TIMEOUT_MS`. `allow` passes it to the app, and `deny` answers
|
||||||
answers `503`. It does not apply to an answer `discriminator` gives, such as
|
`503`. It does not apply to an answer `servewaf` gives, such as an error.
|
||||||
an error.
|
|
||||||
- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or
|
- `SWRP_MAX_REQUEST_SIZE` (default `100M`): the largest request body. A size or
|
||||||
`off`.
|
`off`.
|
||||||
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or
|
- `SWRP_MAX_RESPONSE_SIZE` (default `1G`): the largest response body. A size or
|
||||||
@@ -450,22 +383,22 @@ A time here is a whole number of milliseconds above zero, such as `2000`.
|
|||||||
|
|
||||||
## The image's command
|
## The image's command
|
||||||
|
|
||||||
`SWW_COMMAND` (default `rproxy`) chooses what the image runs and what its health
|
`SMALLWEBWAF_COMMAND` (default `rproxy`) chooses what the image runs and what
|
||||||
check checks: `rproxy` or `discriminator` (see "Deployment" in
|
its health check checks: `rproxy` or `servewaf` (see "Deployment" in
|
||||||
[`SPEC.md`](../SPEC.md)). It cannot be given as a file.
|
[`SPEC.md`](../SPEC.md)). It cannot be given as a file.
|
||||||
|
|
||||||
## Where earlier settings went
|
## Where earlier settings went
|
||||||
|
|
||||||
Nothing reads these names any more:
|
Nothing reads these names any more:
|
||||||
|
|
||||||
- `SWW_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`.
|
- `SWWAF_UPSTREAM_URL` is `SWRP_UPSTREAM_URL`.
|
||||||
- `SWW_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`.
|
- `SWWAF_TRUSTED_PROXIES` is `SWRP_TRUSTED_PROXIES`.
|
||||||
- `SWW_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`.
|
- `SWWAF_REQUEST_MAX_BYTES` is `SWRP_MAX_REQUEST_SIZE`.
|
||||||
- `SWW_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`.
|
- `SWWAF_RESPONSE_MAX_BYTES` is `SWRP_MAX_RESPONSE_SIZE`.
|
||||||
- `SWW_CLIENT_REQUEST_TIMEOUT` and `SWW_UPSTREAM_REQUEST_TIMEOUT` are
|
- `SWWAF_CLIENT_REQUEST_TIMEOUT` and `SWWAF_UPSTREAM_REQUEST_TIMEOUT` are
|
||||||
`SWRP_MAX_REQUEST_TIME_MS`, in milliseconds.
|
`SWRP_MAX_REQUEST_TIME_MS`, in milliseconds.
|
||||||
- `SWW_CLIENT_RESPONSE_TIMEOUT` and `SWW_UPSTREAM_RESPONSE_TIMEOUT` are
|
- `SWWAF_CLIENT_RESPONSE_TIMEOUT` and `SWWAF_UPSTREAM_RESPONSE_TIMEOUT` are
|
||||||
`SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds.
|
`SWRP_MAX_RESPONSE_TIME_MS`, in milliseconds.
|
||||||
- `SWW_CLIENT_IDLE_TIMEOUT` and `SWW_CLIENT_REQUEST_HEADER_MAX_BYTES` have no
|
- `SWWAF_CLIENT_IDLE_TIMEOUT` and `SWWAF_CLIENT_REQUEST_HEADER_MAX_BYTES` have
|
||||||
setting. `rproxy` closes a kept-open connection idle for 120 seconds, and
|
no setting. `rproxy` closes a kept-open connection idle for 120 seconds, and
|
||||||
takes a request line and headers of at most 32 KiB.
|
takes a request line and headers of at most 32 KiB.
|
||||||
+10
-11
@@ -1,13 +1,13 @@
|
|||||||
# State files
|
# State files
|
||||||
|
|
||||||
The five JSON files in `SWW_STATE_DIR`, and what each holds. "Persistent state"
|
The five JSON files in `SWWAF_STATE_DIR`, and what each holds. "Persistent
|
||||||
in [`SPEC.md`](../SPEC.md) says how they are written, read and edited. Each has
|
state" in [`SPEC.md`](../SPEC.md) says how they are written, read and edited.
|
||||||
a top-level `version` of 1.
|
Each has a top-level `version` of 1.
|
||||||
|
|
||||||
## `bans.json`
|
## `bans.json`
|
||||||
|
|
||||||
Active and past bans: every ban an admin made, and up to `SWW_MAX_BANS` of those
|
Active and past bans: every ban an admin made, and up to `SWWAF_MAX_BANS` of
|
||||||
`discriminator` made. Per entry:
|
those `servewaf` made. Per entry:
|
||||||
|
|
||||||
- `netblock`, `start` and `expires` (`null` for a permanent ban);
|
- `netblock`, `start` and `expires` (`null` for a permanent ban);
|
||||||
- `cause`: `attack`, `limit`, `admin` or `crowdsec`;
|
- `cause`: `attack`, `limit`, `admin` or `crowdsec`;
|
||||||
@@ -61,11 +61,10 @@ since it was first seen:
|
|||||||
- the first and last time seen;
|
- the first and last time seen;
|
||||||
- the AS number, AS name and country last looked up, and when;
|
- the AS number, AS name and country last looked up, and when;
|
||||||
- total requests, and how many were forwarded and how many refused;
|
- total requests, and how many were forwarded and how many refused;
|
||||||
- body bytes of its requests, and of the responses it was sent;
|
- bytes in each direction;
|
||||||
- responses by status class;
|
- responses by status class;
|
||||||
- offences by kind: `limit`, `attack`, `blocked_by_block_rule` and
|
- offences by kind: `limit`, `attack`, `rule_blocked` and `waf_blocked`. A file
|
||||||
`waf_blocked`. A file may also hold `token_refused`, which is kept, and which
|
may also hold `token_refused`, which is kept, and which nothing adds to.
|
||||||
nothing adds to.
|
|
||||||
|
|
||||||
## `lookups.json`
|
## `lookups.json`
|
||||||
|
|
||||||
@@ -94,10 +93,10 @@ GeoJS answers, one per line:
|
|||||||
## Sizes
|
## Sizes
|
||||||
|
|
||||||
- `clients.json`: about 1 KiB per client, about 20 MiB at the default
|
- `clients.json`: about 1 KiB per client, about 20 MiB at the default
|
||||||
`SWW_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of
|
`SWWAF_MAX_TRACKED_CLIENTS`. Written every 15 minutes, that is under 2 GiB of
|
||||||
disk writes a day.
|
disk writes a day.
|
||||||
- `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at
|
- `bans.json`: about 1.2 KiB per ban, at most about 2.5 KiB, so about 6 MiB at
|
||||||
the default `SWW_MAX_BANS`. Written every 15 minutes, a full file comes to
|
the default `SWWAF_MAX_BANS`. Written every 15 minutes, a full file comes to
|
||||||
about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB
|
about 600 MiB of writes a day, and a hundred new bans a day add about 600 MiB
|
||||||
more.
|
more.
|
||||||
- `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when
|
- `lookups.json`: about 150 bytes per answer, under 1.5 GiB of writes a day when
|
||||||
|
|||||||
+36
-36
@@ -1,12 +1,12 @@
|
|||||||
// Package alerts sends alerts on bans, on traffic over an anomaly
|
// Package alerts sends alerts on bans, on traffic over an anomaly
|
||||||
// threshold, on a source that fails and on a file with an error to each
|
// threshold, on a source that fails and on a file with an error to each
|
||||||
// destination set: to the webhook
|
// destination set: to the webhook
|
||||||
// SWW_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
|
// SWWAF_ALERT_WEBHOOK_URL names, each as one JSON object, as "The JSON
|
||||||
// object" in docs/alerts.md describes, to the Slack incoming
|
// object" in docs/alerts.md describes, to the Slack incoming
|
||||||
// webhook SWW_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
|
// webhook SWWAF_ALERT_SLACK_WEBHOOK_URL names, as a message, and to the
|
||||||
// ntfy topic SWW_ALERT_NTFY_URL names. A repeat within
|
// ntfy topic SWWAF_ALERT_NTFY_URL names. A repeat within
|
||||||
// SWW_ALERT_COOLDOWN is held back, and so is an alert past
|
// SWWAF_ALERT_COOLDOWN is held back, and so is an alert past
|
||||||
// SWW_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
|
// SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary. The others wait in a
|
||||||
// bounded queue of each destination's own, so that a destination that is
|
// bounded queue of each destination's own, so that a destination that is
|
||||||
// slow or unreachable holds up neither the others nor any request. The
|
// slow or unreachable holds up neither the others nor any request. The
|
||||||
// state is written to alerts.json and read from it by the state package.
|
// state is written to alerts.json and read from it by the state package.
|
||||||
@@ -34,41 +34,41 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The events an alert is for, as SWW_ALERT_EVENTS names them.
|
// The events an alert is for, as SWWAF_ALERT_EVENTS names them.
|
||||||
const (
|
const (
|
||||||
// EventBan is a ban sww made.
|
// EventBan is a ban smallwebwaf made.
|
||||||
EventBan = "ban"
|
EventBan = "ban"
|
||||||
// EventPermanentBan is a permanent ban sww made, or a ban it
|
// EventPermanentBan is a permanent ban smallwebwaf made, or a ban it
|
||||||
// made permanent.
|
// made permanent.
|
||||||
EventPermanentBan = "permanent_ban"
|
EventPermanentBan = "permanent_ban"
|
||||||
// EventAnomaly is a count of requests or bytes over an anomaly
|
// EventAnomaly is a count of requests or bytes over an anomaly
|
||||||
// threshold.
|
// threshold.
|
||||||
EventAnomaly = "anomaly"
|
EventAnomaly = "anomaly"
|
||||||
// EventWAFBlock is a request the Core Rule Set scored at or over
|
// EventWAFBlock is a request the Core Rule Set scored at or over
|
||||||
// SWW_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
|
// SWWAF_WAF_ANOMALY_THRESHOLD, refused in block mode, let through in
|
||||||
// detect mode.
|
// detect mode.
|
||||||
EventWAFBlock = "waf_block"
|
EventWAFBlock = "waf_block"
|
||||||
// EventReputationHit is a request whose client a blocklist, the
|
// EventReputationHit is a request whose client a blocklist, the
|
||||||
// CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score
|
// CrowdSec decision list or a DNSBL zone lists, or whose AbuseIPDB score
|
||||||
// is a hit.
|
// is a hit.
|
||||||
EventReputationHit = "reputation_hit"
|
EventReputationHit = "reputation_hit"
|
||||||
// EventSourceFailure is GeoJS failing or refusing sww, a fetch
|
// EventSourceFailure is GeoJS failing or refusing smallwebwaf, a fetch
|
||||||
// of a list failing, a query to a DNSBL zone or a check with AbuseIPDB
|
// of a list failing, a query to a DNSBL zone or a check with AbuseIPDB
|
||||||
// failing or refused, or the day's AbuseIPDB checks used up.
|
// failing or refused, or the day's AbuseIPDB checks used up.
|
||||||
EventSourceFailure = "source_failure"
|
EventSourceFailure = "source_failure"
|
||||||
// EventFileError is a rule file or state file edited while sww
|
// EventFileError is a rule file or state file edited while smallwebwaf
|
||||||
// runs that does not parse, a replacement of the lookup database that
|
// runs that does not parse, a replacement of the lookup database that
|
||||||
// cannot be read, or a state file that cannot be written.
|
// cannot be read, or a state file that cannot be written.
|
||||||
EventFileError = "file_error"
|
EventFileError = "file_error"
|
||||||
// EventSummary is the summary sent as an hour ends: of the alerts held
|
// EventSummary is the summary sent as an hour ends: of the alerts held
|
||||||
// back in it past SWW_ALERT_MAX_PER_HOUR, and of the repeats held
|
// back in it past SWWAF_ALERT_MAX_PER_HOUR, and of the repeats held
|
||||||
// back by the cooldowns dropped as it ends, which no alert let through
|
// back by the cooldowns dropped as it ends, which no alert let through
|
||||||
// has given. It is sent with SWW_ALERT_MAX_PER_HOUR off too, for
|
// has given. It is sent with SWWAF_ALERT_MAX_PER_HOUR off too, for
|
||||||
// those repeats. SWW_ALERT_EVENTS does not name it.
|
// those repeats. SWWAF_ALERT_EVENTS does not name it.
|
||||||
EventSummary = "summary"
|
EventSummary = "summary"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Events returns every event SWW_ALERT_EVENTS can name, which is its
|
// Events returns every event SWWAF_ALERT_EVENTS can name, which is its
|
||||||
// default.
|
// default.
|
||||||
func Events() []string {
|
func Events() []string {
|
||||||
return []string{
|
return []string{
|
||||||
@@ -80,12 +80,12 @@ func Events() []string {
|
|||||||
// The destinations alerts are sent to, as the metrics and alerts.json
|
// The destinations alerts are sent to, as the metrics and alerts.json
|
||||||
// name them.
|
// name them.
|
||||||
const (
|
const (
|
||||||
// DestinationWebhook is the webhook SWW_ALERT_WEBHOOK_URL names.
|
// DestinationWebhook is the webhook SWWAF_ALERT_WEBHOOK_URL names.
|
||||||
DestinationWebhook = "webhook"
|
DestinationWebhook = "webhook"
|
||||||
// DestinationSlack is the Slack incoming webhook
|
// DestinationSlack is the Slack incoming webhook
|
||||||
// SWW_ALERT_SLACK_WEBHOOK_URL names.
|
// SWWAF_ALERT_SLACK_WEBHOOK_URL names.
|
||||||
DestinationSlack = "slack"
|
DestinationSlack = "slack"
|
||||||
// DestinationNtfy is the ntfy topic SWW_ALERT_NTFY_URL names.
|
// DestinationNtfy is the ntfy topic SWWAF_ALERT_NTFY_URL names.
|
||||||
DestinationNtfy = "ntfy"
|
DestinationNtfy = "ntfy"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,26 +121,26 @@ var (
|
|||||||
// NtfyURL set, no alert is sent.
|
// NtfyURL set, no alert is sent.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// WebhookURL is where each alert is posted as JSON
|
// WebhookURL is where each alert is posted as JSON
|
||||||
// (SWW_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
|
// (SWWAF_ALERT_WEBHOOK_URL), nil while it is unset. WebhookHeaders
|
||||||
// are sent with each (SWW_ALERT_WEBHOOK_HEADERS).
|
// are sent with each (SWWAF_ALERT_WEBHOOK_HEADERS).
|
||||||
WebhookURL *url.URL
|
WebhookURL *url.URL
|
||||||
WebhookHeaders http.Header
|
WebhookHeaders http.Header
|
||||||
// SlackURL is the Slack incoming webhook each alert is posted to as a
|
// SlackURL is the Slack incoming webhook each alert is posted to as a
|
||||||
// message (SWW_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
|
// message (SWWAF_ALERT_SLACK_WEBHOOK_URL), nil while it is unset.
|
||||||
SlackURL *url.URL
|
SlackURL *url.URL
|
||||||
// NtfyURL is the ntfy topic each alert is published to
|
// NtfyURL is the ntfy topic each alert is published to
|
||||||
// (SWW_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
|
// (SWWAF_ALERT_NTFY_URL), nil while it is unset. NtfyToken, unless
|
||||||
// empty, is sent with each as a bearer token (SWW_ALERT_NTFY_TOKEN).
|
// empty, is sent with each as a bearer token (SWWAF_ALERT_NTFY_TOKEN).
|
||||||
NtfyURL *url.URL
|
NtfyURL *url.URL
|
||||||
NtfyToken string
|
NtfyToken string
|
||||||
// Events are the events alerts are sent for (SWW_ALERT_EVENTS).
|
// Events are the events alerts are sent for (SWWAF_ALERT_EVENTS).
|
||||||
Events []string
|
Events []string
|
||||||
// Cooldown is how long a repeat of an alert is held back
|
// Cooldown is how long a repeat of an alert is held back
|
||||||
// (SWW_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
|
// (SWWAF_ALERT_COOLDOWN), 0 for no time. MaxPerHour is the most alerts
|
||||||
// sent in an hour (SWW_ALERT_MAX_PER_HOUR), 0 for no limit.
|
// sent in an hour (SWWAF_ALERT_MAX_PER_HOUR), 0 for no limit.
|
||||||
Cooldown time.Duration
|
Cooldown time.Duration
|
||||||
MaxPerHour int
|
MaxPerHour int
|
||||||
// Instance is SWW_INSTANCE_NAME, which every alert gives.
|
// Instance is SWWAF_INSTANCE_NAME, which every alert gives.
|
||||||
Instance string
|
Instance string
|
||||||
// Now tells the time of an alert, normally time.Now in UTC.
|
// Now tells the time of an alert, normally time.Now in UTC.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
@@ -151,7 +151,7 @@ type Params struct {
|
|||||||
// Alert is one alert, as the webhook is sent it and alerts.json holds it,
|
// Alert is one alert, as the webhook is sent it and alerts.json holds it,
|
||||||
// with the fields of "The JSON object" in docs/alerts.md. ASN, ASName and
|
// with the fields of "The JSON object" in docs/alerts.md. ASN, ASName and
|
||||||
// Country are, for a ban, the client's as the ban's notes give them. Host is
|
// Country are, for a ban, the client's as the ban's notes give them. Host is
|
||||||
// the site of the request that raised the alert, while one discriminator serves
|
// the site of the request that raised the alert, while one servewaf serves
|
||||||
// many sites, and "" otherwise.
|
// many sites, and "" otherwise.
|
||||||
//
|
//
|
||||||
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
|
//nolint:tagliatelle // docs/alerts.md names the alert's fields in snake_case
|
||||||
@@ -301,17 +301,17 @@ func New(params Params) *Queue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if params.WebhookURL != nil {
|
if params.WebhookURL != nil {
|
||||||
q.addDestination(DestinationWebhook, "SWW_ALERT_WEBHOOK_URL",
|
q.addDestination(DestinationWebhook, "SWWAF_ALERT_WEBHOOK_URL",
|
||||||
params.WebhookURL, q.webhookMessage)
|
params.WebhookURL, q.webhookMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
if params.SlackURL != nil {
|
if params.SlackURL != nil {
|
||||||
q.addDestination(DestinationSlack, "SWW_ALERT_SLACK_WEBHOOK_URL",
|
q.addDestination(DestinationSlack, "SWWAF_ALERT_SLACK_WEBHOOK_URL",
|
||||||
params.SlackURL, slackMessage)
|
params.SlackURL, slackMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
if params.NtfyURL != nil {
|
if params.NtfyURL != nil {
|
||||||
q.addDestination(DestinationNtfy, "SWW_ALERT_NTFY_URL",
|
q.addDestination(DestinationNtfy, "SWWAF_ALERT_NTFY_URL",
|
||||||
params.NtfyURL, q.ntfyMessage)
|
params.NtfyURL, q.ntfyMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +319,7 @@ func New(params Params) *Queue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Raise sends alert, which names its event and what is particular to it,
|
// Raise sends alert, which names its event and what is particular to it,
|
||||||
// unless no destination is set or SWW_ALERT_EVENTS leaves its event
|
// unless no destination is set or SWWAF_ALERT_EVENTS leaves its event
|
||||||
// out. It gives alert the instance and the time. An alert that repeats
|
// out. It gives alert the instance and the time. An alert that repeats
|
||||||
// the last one let through less than Cooldown before is held back and
|
// the last one let through less than Cooldown before is held back and
|
||||||
// counted. The next one let through gives that count, unless an hour of
|
// counted. The next one let through gives that count, unless an hour of
|
||||||
@@ -363,7 +363,7 @@ func (q *Queue) Raise(alert Alert) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WouldSend reports whether Raise would let an alert for event on
|
// WouldSend reports whether Raise would let an alert for event on
|
||||||
// netblock through now: a destination is set, SWW_ALERT_EVENTS chooses
|
// netblock through now: a destination is set, SWWAF_ALERT_EVENTS chooses
|
||||||
// event, no alert for event on netblock was let through less than
|
// event, no alert for event on netblock was let through less than
|
||||||
// Cooldown before, and fewer than MaxPerHour alerts have been let through
|
// Cooldown before, and fewer than MaxPerHour alerts have been let through
|
||||||
// in the hour under way. Unlike Raise, it counts nothing.
|
// in the hour under way. Unlike Raise, it counts nothing.
|
||||||
@@ -606,13 +606,13 @@ func (q *Queue) endHour(now time.Time) {
|
|||||||
|
|
||||||
if heldBack > 0 {
|
if heldBack > 0 {
|
||||||
reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
|
reasons = append(reasons, fmt.Sprintf("%d alerts held back in the hour from %s, "+
|
||||||
"past the %d an hour SWW_ALERT_MAX_PER_HOUR allows", heldBack,
|
"past the %d an hour SWWAF_ALERT_MAX_PER_HOUR allows", heldBack,
|
||||||
q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
|
q.hour.Start.Format(time.RFC3339), q.params.MaxPerHour))
|
||||||
}
|
}
|
||||||
|
|
||||||
if repeats > 0 {
|
if repeats > 0 {
|
||||||
reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+
|
reasons = append(reasons, fmt.Sprintf("%d repeats held back by "+
|
||||||
"SWW_ALERT_COOLDOWN that no later alert gives", repeats))
|
"SWWAF_ALERT_COOLDOWN that no later alert gives", repeats))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(reasons) > 0 {
|
if len(reasons) > 0 {
|
||||||
@@ -683,7 +683,7 @@ func (d *destination) run(ctx context.Context) {
|
|||||||
|
|
||||||
d.processLog.Warn("gave up an alert "+d.setting+" refused",
|
d.processLog.Warn("gave up an alert "+d.setting+" refused",
|
||||||
"event", alert.Event, "error", err.Error())
|
"event", alert.Event, "error", err.Error())
|
||||||
case ctx.Err() == nil: // not cut off as sww stops
|
case ctx.Err() == nil: // not cut off as smallwebwaf stops
|
||||||
d.failed.Add(1)
|
d.failed.Add(1)
|
||||||
|
|
||||||
retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay),
|
retryDelay = min(max(retryDelayFactor*retryDelay, firstRetryDelay),
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ const (
|
|||||||
// webhookURL is where the alerts are posted, slackURL the Slack
|
// webhookURL is where the alerts are posted, slackURL the Slack
|
||||||
// incoming webhook, and ntfyURL the ntfy topic. ntfyToken is the ntfy
|
// incoming webhook, and ntfyURL the ntfy topic. ntfyToken is the ntfy
|
||||||
// token of the tests that set one.
|
// token of the tests that set one.
|
||||||
webhookURL = "https://alerts.example/sww?team=ops"
|
webhookURL = "https://alerts.example/smallwebwaf?team=ops"
|
||||||
slackURL = "https://hooks.slack.example/services/T0123/B4567/abcdef"
|
slackURL = "https://hooks.slack.example/services/T0123/B4567/abcdef"
|
||||||
ntfyURL = "https://ntfy.example/sww-alerts"
|
ntfyURL = "https://ntfy.example/smallwebwaf-alerts"
|
||||||
ntfyToken = "tk_0123456789abcdefghijklmnopq"
|
ntfyToken = "tk_0123456789abcdefghijklmnopq"
|
||||||
// instance is the instance name every alert gives.
|
// instance is the instance name every alert gives.
|
||||||
instance = "fsn1app1/blog"
|
instance = "fsn1app1/blog"
|
||||||
@@ -132,7 +132,7 @@ func TestWouldSendOnlyForTheChosenEvents(t *testing.T) {
|
|||||||
|
|
||||||
if q.WouldSend(alerts.EventBan, netblock(1)) ||
|
if q.WouldSend(alerts.EventBan, netblock(1)) ||
|
||||||
q.WouldSend(alerts.EventPermanentBan, netblock(1)) {
|
q.WouldSend(alerts.EventPermanentBan, netblock(1)) {
|
||||||
t.Error("a ban alert would be sent, though SWW_ALERT_EVENTS leaves it out")
|
t.Error("a ban alert would be sent, though SWWAF_ALERT_EVENTS leaves it out")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) {
|
if !q.WouldSend(alerts.EventFileError, netip.Prefix{}) {
|
||||||
@@ -184,8 +184,8 @@ func TestWouldSendWithOnlySlackOrOnlyNtfySet(t *testing.T) {
|
|||||||
onlyNtfy.NtfyURL = parseURL(ntfyURL)
|
onlyNtfy.NtfyURL = parseURL(ntfyURL)
|
||||||
|
|
||||||
for setting, params := range map[string]alerts.Params{
|
for setting, params := range map[string]alerts.Params{
|
||||||
"SWW_ALERT_SLACK_WEBHOOK_URL": onlySlack,
|
"SWWAF_ALERT_SLACK_WEBHOOK_URL": onlySlack,
|
||||||
"SWW_ALERT_NTFY_URL": onlyNtfy,
|
"SWWAF_ALERT_NTFY_URL": onlyNtfy,
|
||||||
} {
|
} {
|
||||||
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
|
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
|
||||||
t.Errorf("a ban alert would not be sent with only %s set", setting)
|
t.Errorf("a ban alert would not be sent with only %s set", setting)
|
||||||
@@ -359,7 +359,7 @@ func TestAlertsPastTheHourlyLimitAreRolledIntoOneSummary(t *testing.T) {
|
|||||||
"as_name": "",
|
"as_name": "",
|
||||||
"country": "",
|
"country": "",
|
||||||
"reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
"reason": "3 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
||||||
"past the 2 an hour SWW_ALERT_MAX_PER_HOUR allows",
|
"past the 2 an hour SWWAF_ALERT_MAX_PER_HOUR allows",
|
||||||
"detail": map[string]any{
|
"detail": map[string]any{
|
||||||
"hour": started,
|
"hour": started,
|
||||||
"count": float64(3),
|
"count": float64(3),
|
||||||
@@ -525,7 +525,7 @@ func TestFailedRequestIsSentAgainWithBackoff(t *testing.T) {
|
|||||||
wantCounts(t, q, 0, int64(len(want)), 0, 0)
|
wantCounts(t, q, 0, int64(len(want)), 0, 0)
|
||||||
|
|
||||||
if !strings.Contains(log.String(),
|
if !strings.Contains(log.String(),
|
||||||
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) {
|
`"msg":"sending an alert to SWWAF_ALERT_WEBHOOK_URL failed"`) {
|
||||||
t.Errorf("process log %q names no failure", log.String())
|
t.Errorf("process log %q names no failure", log.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,7 +595,7 @@ func TestRefusedAlertIsGivenUpAndTheNextSent(t *testing.T) {
|
|||||||
wantCounts(t, q, 1, 3, 0, 1)
|
wantCounts(t, q, 1, 3, 0, 1)
|
||||||
|
|
||||||
if !strings.Contains(log.String(),
|
if !strings.Contains(log.String(),
|
||||||
`"msg":"gave up an alert SWW_ALERT_WEBHOOK_URL refused"`) {
|
`"msg":"gave up an alert SWWAF_ALERT_WEBHOOK_URL refused"`) {
|
||||||
t.Errorf("process log %q names no alert given up", log.String())
|
t.Errorf("process log %q names no alert given up", log.String())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -619,7 +619,7 @@ func TestFailedRequestIsLoggedWithoutTheURL(t *testing.T) {
|
|||||||
|
|
||||||
logged := log.String()
|
logged := log.String()
|
||||||
if !strings.Contains(logged,
|
if !strings.Contains(logged,
|
||||||
`"msg":"sending an alert to SWW_ALERT_WEBHOOK_URL failed"`) ||
|
`"msg":"sending an alert to SWWAF_ALERT_WEBHOOK_URL failed"`) ||
|
||||||
strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") {
|
strings.Contains(logged, "alerts.example") || strings.Contains(logged, "team=ops") {
|
||||||
t.Errorf("process log %q names no failure, or names the URL", logged)
|
t.Errorf("process log %q names no failure, or names the URL", logged)
|
||||||
}
|
}
|
||||||
@@ -782,8 +782,8 @@ func TestSlackAndNtfyAreSentTheSummaryAndTheRepeatsHeldBack(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
const summary = "1 alerts held back in the hour from 2000-01-01T00:00:00Z, " +
|
||||||
"past the 1 an hour SWW_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
|
"past the 1 an hour SWWAF_ALERT_MAX_PER_HOUR allows; 1 repeats held back " +
|
||||||
"by SWW_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
|
"by SWWAF_ALERT_COOLDOWN that no later alert gives\nsuppressed repeats: 1"
|
||||||
|
|
||||||
wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary)
|
wantSlackMessage(t, slack[1], "*"+instance+": summary*\n"+summary)
|
||||||
wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary)
|
wantNtfyMessage(t, ntfy[1], instance+": summary", "default bar_chart", summary)
|
||||||
@@ -901,7 +901,7 @@ func TestDestinationThatDoesNotAnswerHoldsUpNeitherOther(t *testing.T) {
|
|||||||
|
|
||||||
logged := log.String()
|
logged := log.String()
|
||||||
if !strings.Contains(logged,
|
if !strings.Contains(logged,
|
||||||
`"msg":"sending an alert to SWW_ALERT_SLACK_WEBHOOK_URL failed"`) ||
|
`"msg":"sending an alert to SWWAF_ALERT_SLACK_WEBHOOK_URL failed"`) ||
|
||||||
strings.Contains(logged, "hooks.slack.example") ||
|
strings.Contains(logged, "hooks.slack.example") ||
|
||||||
strings.Contains(logged, "T0123") {
|
strings.Contains(logged, "T0123") {
|
||||||
t.Errorf("process log %q names no failure, or names the URL", logged)
|
t.Errorf("process log %q names no failure, or names the URL", logged)
|
||||||
@@ -1254,7 +1254,7 @@ type eventMessage struct {
|
|||||||
title, priorityAndTag, text string
|
title, priorityAndTag, text string
|
||||||
}
|
}
|
||||||
|
|
||||||
// anAlertForEachEvent returns an alert for each event SWW_ALERT_EVENTS
|
// anAlertForEachEvent returns an alert for each event SWWAF_ALERT_EVENTS
|
||||||
// names, the first in observe mode, each with its message.
|
// names, the first in observe mode, each with its message.
|
||||||
func anAlertForEachEvent() []eventMessage {
|
func anAlertForEachEvent() []eventMessage {
|
||||||
return []eventMessage{
|
return []eventMessage{
|
||||||
@@ -1320,12 +1320,12 @@ func anAlertForEachEvent() []eventMessage {
|
|||||||
Event: alerts.EventFileError,
|
Event: alerts.EventFileError,
|
||||||
Reason: "a rule file has an error, and the rules stay as they were",
|
Reason: "a rule file has an error, and the rules stay as they were",
|
||||||
Detail: map[string]any{
|
Detail: map[string]any{
|
||||||
"file": "/etc/sww/rules.d/50-app.rules", "error": "line 2: no action",
|
"file": "/etc/smallwebwaf/rules.d/50-app.rules", "error": "line 2: no action",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"fsn1app1/blog: file_error", "high warning",
|
"fsn1app1/blog: file_error", "high warning",
|
||||||
"a rule file has an error, and the rules stay as they were\n" +
|
"a rule file has an error, and the rules stay as they were\n" +
|
||||||
"file: /etc/sww/rules.d/50-app.rules\nerror: line 2: no action",
|
"file: /etc/smallwebwaf/rules.d/50-app.rules\nerror: line 2: no action",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-12
@@ -28,16 +28,16 @@ const maxCounters = 20000
|
|||||||
// alerts name them.
|
// alerts name them.
|
||||||
const (
|
const (
|
||||||
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
|
// ScopeClient is one client: an IPv4 address, or an IPv6 netblock of
|
||||||
// SWW_IPV6_GROUP_PREFIX.
|
// SWWAF_IPV6_GROUP_PREFIX.
|
||||||
ScopeClient = "client"
|
ScopeClient = "client"
|
||||||
// ScopeNet is the netblock around a client, SWW_ANOMALY_NET_V4_PREFIX
|
// ScopeNet is the netblock around a client, SWWAF_ANOMALY_NET_V4_PREFIX
|
||||||
// or SWW_ANOMALY_NET_V6_PREFIX long.
|
// or SWWAF_ANOMALY_NET_V6_PREFIX long.
|
||||||
ScopeNet = "net"
|
ScopeNet = "net"
|
||||||
// ScopeASN is an AS number.
|
// ScopeASN is an AS number.
|
||||||
ScopeASN = "asn"
|
ScopeASN = "asn"
|
||||||
// ScopeTotal is the whole service.
|
// ScopeTotal is the whole service.
|
||||||
ScopeTotal = "total"
|
ScopeTotal = "total"
|
||||||
// ScopeWatch is a named netblock of SWW_WATCH_NETS.
|
// ScopeWatch is a named netblock of SWWAF_WATCH_NETS.
|
||||||
ScopeWatch = "watch"
|
ScopeWatch = "watch"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ type Thresholds struct {
|
|||||||
BytesPerHour int64
|
BytesPerHour int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NamedNetblock is a netblock SWW_WATCH_NETS names.
|
// NamedNetblock is a netblock SWWAF_WATCH_NETS names.
|
||||||
type NamedNetblock struct {
|
type NamedNetblock struct {
|
||||||
Name string
|
Name string
|
||||||
Netblock netip.Prefix
|
Netblock netip.Prefix
|
||||||
@@ -70,14 +70,14 @@ type NamedNetblock struct {
|
|||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// The thresholds of each scope: SWW_ANOMALY_CLIENT_*,
|
// The thresholds of each scope: SWWAF_ANOMALY_CLIENT_*,
|
||||||
// SWW_ANOMALY_NET_*, SWW_ANOMALY_ASN_*, SWW_ANOMALY_TOTAL_* and
|
// SWWAF_ANOMALY_NET_*, SWWAF_ANOMALY_ASN_*, SWWAF_ANOMALY_TOTAL_* and
|
||||||
// SWW_WATCH_*.
|
// SWWAF_WATCH_*.
|
||||||
Client, Net, ASN, Total, Watch Thresholds
|
Client, Net, ASN, Total, Watch Thresholds
|
||||||
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
|
// NetV4Prefix and NetV6Prefix are the lengths of the netblock around a
|
||||||
// client (SWW_ANOMALY_NET_V4_PREFIX and SWW_ANOMALY_NET_V6_PREFIX).
|
// client (SWWAF_ANOMALY_NET_V4_PREFIX and SWWAF_ANOMALY_NET_V6_PREFIX).
|
||||||
NetV4Prefix, NetV6Prefix int
|
NetV4Prefix, NetV6Prefix int
|
||||||
// NamedNetblocks are SWW_WATCH_NETS.
|
// NamedNetblocks are SWWAF_WATCH_NETS.
|
||||||
NamedNetblocks []NamedNetblock
|
NamedNetblocks []NamedNetblock
|
||||||
// Alerts receive the anomaly alerts.
|
// Alerts receive the anomaly alerts.
|
||||||
Alerts *alerts.Queue
|
Alerts *alerts.Queue
|
||||||
@@ -107,13 +107,13 @@ type Request struct {
|
|||||||
Host string
|
Host string
|
||||||
// Client is the client's address, and ClientGroup the client it is
|
// Client is the client's address, and ClientGroup the client it is
|
||||||
// counted as: its IPv4 address, or the IPv6 netblock of
|
// counted as: its IPv4 address, or the IPv6 netblock of
|
||||||
// SWW_IPV6_GROUP_PREFIX its address is in.
|
// SWWAF_IPV6_GROUP_PREFIX its address is in.
|
||||||
Client netip.Addr
|
Client netip.Addr
|
||||||
ClientGroup netip.Prefix
|
ClientGroup netip.Prefix
|
||||||
// ASN, ASName and Country are the client's as looked up, each "" when
|
// ASN, ASName and Country are the client's as looked up, each "" when
|
||||||
// unknown.
|
// unknown.
|
||||||
ASN, ASName, Country string
|
ASN, ASName, Country string
|
||||||
// Bytes are the request's bytes, as SWW_BYTES_COUNT counts them.
|
// Bytes are the request's bytes, as SWWAF_BYTES_COUNT counts them.
|
||||||
Bytes int64
|
Bytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
|
|||||||
third := netip.MustParsePrefix("203.0.113.3/32")
|
third := netip.MustParsePrefix("203.0.113.3/32")
|
||||||
|
|
||||||
// Seen longest ago, a netblock with two of an admin's bans alone, and
|
// Seen longest ago, a netblock with two of an admin's bans alone, and
|
||||||
// then one with an admin's ban before a ban sww made: the one
|
// then one with an admin's ban before a ban smallwebwaf made: the one
|
||||||
// ban counted toward MaxBans.
|
// ban counted toward MaxBans.
|
||||||
ledger.Load([]bans.Ban{
|
ledger.Load([]bans.Ban{
|
||||||
{Netblock: adminsOnly, Start: midnight().Add(-3 * time.Hour), Cause: bans.CauseAdmin},
|
{Netblock: adminsOnly, Start: midnight().Add(-3 * time.Hour), Cause: bans.CauseAdmin},
|
||||||
@@ -48,7 +48,7 @@ func TestAdminsBansAreNeverDroppedAndDoNotCountTowardMaxBans(t *testing.T) {
|
|||||||
})
|
})
|
||||||
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 2})
|
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 2})
|
||||||
|
|
||||||
// A new ban drops the ban sww made, and only that one.
|
// A new ban drops the ban smallwebwaf made, and only that one.
|
||||||
ledger.BanForLimit(second, midnight(), bans.Notes{})
|
ledger.BanForLimit(second, midnight(), bans.Notes{})
|
||||||
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1})
|
wantBans(t, ledger, map[netip.Prefix]int{adminsOnly: 2, both: 1, second: 1})
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ func TestEarlierBansCountABanAnAdminKeepsOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReasonOfTheBansSwwMakes(t *testing.T) {
|
func TestReasonOfTheBansSmallwebwafMakes(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ledger := bans.New(defaultRules())
|
ledger := bans.New(defaultRules())
|
||||||
@@ -226,7 +226,7 @@ func TestLoadEditCountsTheBansAnAdminMade(t *testing.T) {
|
|||||||
t.Fatalf("%d bans made by an admin after the start's, want none", got)
|
t.Fatalf("%d bans made by an admin after the start's, want none", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The admin keeps the ban sww made, keeps the one read at the
|
// The admin keeps the ban smallwebwaf made, keeps the one read at the
|
||||||
// start, and adds one without a cause: that one alone is made.
|
// start, and adds one without a cause: that one alone is made.
|
||||||
kept := made
|
kept := made
|
||||||
kept.Cause = bans.CauseAdmin
|
kept.Cause = bans.CauseAdmin
|
||||||
@@ -341,48 +341,3 @@ func TestLiftLiftsEveryActiveBanCoveringTheClient(t *testing.T) {
|
|||||||
|
|
||||||
wantChanged(t, ledger, false)
|
wantChanged(t, ledger, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPermanentBanOnAWiderNetblockIsReturnedOverATemporaryBan(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
client := netip.MustParseAddr("203.0.113.9")
|
|
||||||
ledger := bans.New(defaultRules())
|
|
||||||
|
|
||||||
// The temporary ban is made first, so that its netblock is looked at first.
|
|
||||||
ledger.BanForLimit(netip.MustParsePrefix("203.0.113.9/32"), midnight(), bans.Notes{})
|
|
||||||
ledger.BanForAdmin(netip.MustParsePrefix("203.0.113.0/24"), midnight(), time.Time{},
|
|
||||||
"for good")
|
|
||||||
|
|
||||||
wantPermanentBan(t, ledger, client, midnight().Add(time.Minute))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOlderPermanentBanIsReturnedOverANewerTemporaryBan(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
|
||||||
ledger := bans.New(defaultRules())
|
|
||||||
|
|
||||||
ledger.BanForAdmin(netblock, midnight(), time.Time{}, "for good")
|
|
||||||
ledger.BanForAdmin(netblock, midnight().Add(time.Minute), midnight().Add(time.Hour),
|
|
||||||
"for an hour")
|
|
||||||
|
|
||||||
wantPermanentBan(t, ledger, netblock.Addr(), midnight().Add(2*time.Minute))
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantPermanentBan checks that Find and Check return a permanent ban for
|
|
||||||
// client at now.
|
|
||||||
func wantPermanentBan(
|
|
||||||
t *testing.T, ledger *bans.Ledger, client netip.Addr, now time.Time,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for name, look := range map[string]func(netip.Addr, time.Time) (bans.Ban, bool, bool){
|
|
||||||
"Find": ledger.Find,
|
|
||||||
"Check": ledger.Check,
|
|
||||||
} {
|
|
||||||
ban, banned, _ := look(client, now)
|
|
||||||
if !banned || !ban.Permanent() {
|
|
||||||
t.Errorf("%s returned %+v and banned %v, want a permanent ban", name, ban, banned)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+16
-38
@@ -1,4 +1,4 @@
|
|||||||
// Package bans is the ban ledger: the bans sww makes on the
|
// Package bans is the ban ledger: the bans smallwebwaf makes on the
|
||||||
// netblocks of clients that break a rate limit, a byte limit or the error
|
// netblocks of clients that break a rate limit, a byte limit or the error
|
||||||
// burst, show a clear sign of attack or are listed by the CrowdSec
|
// burst, show a clear sign of attack or are listed by the CrowdSec
|
||||||
// decision list, and
|
// decision list, and
|
||||||
@@ -21,14 +21,14 @@ import (
|
|||||||
|
|
||||||
// The causes of bans.
|
// The causes of bans.
|
||||||
const (
|
const (
|
||||||
// CauseLimit is a ban sww made for a broken limit.
|
// CauseLimit is a ban smallwebwaf made for a broken limit.
|
||||||
CauseLimit = "limit"
|
CauseLimit = "limit"
|
||||||
// CauseAttack is a ban sww made for a clear sign of attack.
|
// CauseAttack is a ban smallwebwaf made for a clear sign of attack.
|
||||||
CauseAttack = "attack"
|
CauseAttack = "attack"
|
||||||
// CauseAdmin is a ban an admin made, or one sww made that an
|
// CauseAdmin is a ban an admin made, or one smallwebwaf made that an
|
||||||
// admin keeps. It is never dropped.
|
// admin keeps. It is never dropped.
|
||||||
CauseAdmin = "admin"
|
CauseAdmin = "admin"
|
||||||
// CauseCrowdSec is a ban sww made for a client the CrowdSec
|
// CauseCrowdSec is a ban smallwebwaf made for a client the CrowdSec
|
||||||
// decision list lists. It ends when CrowdSec's decision does.
|
// decision list lists. It ends when CrowdSec's decision does.
|
||||||
CauseCrowdSec = "crowdsec"
|
CauseCrowdSec = "crowdsec"
|
||||||
)
|
)
|
||||||
@@ -70,7 +70,7 @@ type Ban struct {
|
|||||||
Expires time.Time
|
Expires time.Time
|
||||||
// Cause is CauseLimit, CauseAttack, CauseAdmin or CauseCrowdSec.
|
// Cause is CauseLimit, CauseAttack, CauseAdmin or CauseCrowdSec.
|
||||||
Cause string
|
Cause string
|
||||||
// Reason is a short text: for a ban sww made, the limit broken,
|
// Reason is a short text: for a ban smallwebwaf made, the limit broken,
|
||||||
// the rule that matched or the scenario of CrowdSec's decision; for an
|
// the rule that matched or the scenario of CrowdSec's decision; for an
|
||||||
// admin's, what the admin wrote.
|
// admin's, what the admin wrote.
|
||||||
Reason string
|
Reason string
|
||||||
@@ -123,7 +123,7 @@ type Notes struct {
|
|||||||
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
|
LimitPercentSetting string `json:"limit_percent_setting,omitempty"`
|
||||||
// RuleID and Target are, for a ban for a clear sign of attack, the id
|
// RuleID and Target are, for a ban for a clear sign of attack, the id
|
||||||
// of the rule file rule that matched, and its target; TrapPath is, for
|
// of the rule file rule that matched, and its target; TrapPath is, for
|
||||||
// one for a request for a path in SWW_TRAP_PATHS, that path as the
|
// one for a request for a path in SWWAF_TRAP_PATHS, that path as the
|
||||||
// client sent it.
|
// client sent it.
|
||||||
RuleID string `json:"rule_id,omitempty"`
|
RuleID string `json:"rule_id,omitempty"`
|
||||||
Target string `json:"target,omitempty"`
|
Target string `json:"target,omitempty"`
|
||||||
@@ -199,7 +199,7 @@ type Ledger struct {
|
|||||||
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
// v4Lengths and v6Lengths are the lengths of the IPv4 and IPv6
|
||||||
// netblocks that have been banned. Check looks for a ban at each of
|
// netblocks that have been banned. Check looks for a ban at each of
|
||||||
// them, so that a ban read from bans.json refuses every client in its
|
// them, so that a ban read from bans.json refuses every client in its
|
||||||
// netblock even when it was made with another SWW_BAN_SCOPE_V4_PREFIX,
|
// netblock even when it was made with another SWWAF_BAN_SCOPE_V4_PREFIX,
|
||||||
// or another length of an IPv6 client's netblock.
|
// or another length of an IPv6 client's netblock.
|
||||||
v4Lengths, v6Lengths []int
|
v4Lengths, v6Lengths []int
|
||||||
}
|
}
|
||||||
@@ -274,27 +274,16 @@ func (l *Ledger) Find(client netip.Addr, now time.Time) (Ban, bool, bool) {
|
|||||||
|
|
||||||
// activeBan returns the ban in bans, a netblock's bans oldest first, that
|
// activeBan returns the ban in bans, a netblock's bans oldest first, that
|
||||||
// is active at now, or nil when none is. If several are, it returns the
|
// is active at now, or nil when none is. If several are, it returns the
|
||||||
// newest permanent one, or the newest when none is permanent. Every ban is
|
// one that started last. Every ban is looked at, since a ban an admin adds
|
||||||
// looked at, since a ban an admin adds to bans.json can start before the
|
// to bans.json can start before the netblock's others and outlast them.
|
||||||
// netblock's others and outlast them.
|
|
||||||
func activeBan(bans []Ban, now time.Time) *Ban {
|
func activeBan(bans []Ban, now time.Time) *Ban {
|
||||||
var newest *Ban
|
|
||||||
|
|
||||||
for i := len(bans) - 1; i >= 0; i-- {
|
for i := len(bans) - 1; i >= 0; i-- {
|
||||||
if !bans[i].ActiveAt(now) {
|
if bans[i].ActiveAt(now) {
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if bans[i].Permanent() {
|
|
||||||
return &bans[i]
|
return &bans[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
if newest == nil {
|
|
||||||
newest = &bans[i]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return newest
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BanForLimit bans netblock at now for a broken limit, with notes, and
|
// BanForLimit bans netblock at now for a broken limit, with notes, and
|
||||||
@@ -592,7 +581,7 @@ func (l *Ledger) Load(bans []Ban) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LoadEdit is Load for an admin's edit of bans.json, taken in while
|
// LoadEdit is Load for an admin's edit of bans.json, taken in while
|
||||||
// sww runs. Each ban in it whose cause is CauseAdmin, and which
|
// smallwebwaf runs. Each ban in it whose cause is CauseAdmin, and which
|
||||||
// the ledger did not hold, with the same netblock and start, is one the
|
// the ledger did not hold, with the same netblock and start, is one the
|
||||||
// admin made, and is counted among the bans made.
|
// admin made, and is counted among the bans made.
|
||||||
func (l *Ledger) LoadEdit(bans []Ban) {
|
func (l *Ledger) LoadEdit(bans []Ban) {
|
||||||
@@ -754,16 +743,13 @@ func (l *Ledger) markChanged() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// active returns the ban active at now on a netblock client is in, or
|
// active returns the ban active at now on a netblock client is in, or
|
||||||
// nil. If several netblocks have one, it returns a permanent ban when
|
// nil.
|
||||||
// there is one.
|
|
||||||
func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
|
func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
|
||||||
lengths := l.v6Lengths
|
lengths := l.v6Lengths
|
||||||
if client.Is4() {
|
if client.Is4() {
|
||||||
lengths = l.v4Lengths
|
lengths = l.v4Lengths
|
||||||
}
|
}
|
||||||
|
|
||||||
var first *Ban
|
|
||||||
|
|
||||||
for _, length := range lengths {
|
for _, length := range lengths {
|
||||||
bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked())
|
bans, found := l.netblocks.Get(netip.PrefixFrom(client, length).Masked())
|
||||||
if !found {
|
if !found {
|
||||||
@@ -771,20 +757,12 @@ func (l *Ledger) active(client netip.Addr, now time.Time) *Ban {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ban := activeBan(*bans, now)
|
ban := activeBan(*bans, now)
|
||||||
if ban == nil {
|
if ban != nil {
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if ban.Permanent() {
|
|
||||||
return ban
|
return ban
|
||||||
}
|
}
|
||||||
|
|
||||||
if first == nil {
|
|
||||||
first = ban
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return first
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// covering returns the bans of each netblock held that client is in,
|
// covering returns the bans of each netblock held that client is in,
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ func TestPermanentBanStartedBeforeAnEndedOneRefuses(t *testing.T) {
|
|||||||
func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
func TestNextBanWorkedOutFromTheBanThatEndedLast(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// A 9-hour ban sww made, the third in a row, and an admin's
|
// A 9-hour ban smallwebwaf made, the third in a row, and an admin's
|
||||||
// 1-hour ban added to bans.json over it, with no cause and no notes.
|
// 1-hour ban added to bans.json over it, with no cause and no notes.
|
||||||
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
netblock := netip.MustParsePrefix("203.0.113.9/32")
|
||||||
nineHours := bans.Ban{
|
nineHours := bans.Ban{
|
||||||
|
|||||||
+297
-700
File diff suppressed because it is too large.
Load diff
+170
-432
@@ -23,100 +23,98 @@ import (
|
|||||||
|
|
||||||
// The settings, by name.
|
// The settings, by name.
|
||||||
const (
|
const (
|
||||||
listenAddr = "SWW_LISTEN_ADDR"
|
listenAddr = "SWWAF_LISTEN_ADDR"
|
||||||
mode = "SWW_MODE"
|
mode = "SWWAF_MODE"
|
||||||
ipv6GroupPrefix = "SWW_IPV6_GROUP_PREFIX"
|
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
|
||||||
maxTrackedClients = "SWW_MAX_TRACKED_CLIENTS"
|
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
|
||||||
allowNets = "SWW_ALLOW_NETS"
|
allowNets = "SWWAF_ALLOW_NETS"
|
||||||
rateLimitExemptNets = "SWW_RATE_LIMIT_EXEMPT_NETS"
|
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
||||||
denyNets = "SWW_DENY_NETS"
|
denyNets = "SWWAF_DENY_NETS"
|
||||||
rateLimitPerMinute = "SWW_RATE_LIMIT_PER_MINUTE"
|
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
||||||
rateLimitPerHour = "SWW_RATE_LIMIT_PER_HOUR"
|
rateLimitPerHour = "SWWAF_RATE_LIMIT_PER_HOUR"
|
||||||
rateLimitPerDay = "SWW_RATE_LIMIT_PER_DAY"
|
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||||
rateLimitExemptPaths = "SWW_RATE_LIMIT_EXEMPT_PATHS"
|
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
||||||
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
|
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE"
|
||||||
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
|
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR"
|
||||||
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
|
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY"
|
||||||
bytesCount = "SWW_BYTES_COUNT"
|
bytesCount = "SWWAF_BYTES_COUNT"
|
||||||
lookupSource = "SWW_LOOKUP_SOURCE"
|
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
||||||
lookupDBPath = "SWW_LOOKUP_DB_PATH"
|
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
|
||||||
lookupTimeout = "SWW_LOOKUP_TIMEOUT"
|
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
||||||
addLookupHeaders = "SWW_ADD_LOOKUP_HEADERS"
|
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS"
|
||||||
allowedCountries = "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
||||||
tagLists = "SWW_TAG_LISTS"
|
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
||||||
tagDeny = "SWW_TAG_DENY"
|
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT"
|
||||||
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
|
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT"
|
||||||
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT"
|
||||||
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT"
|
||||||
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT"
|
||||||
asnLimitPercentURL = "SWW_ASN_LIMIT_PERCENT_URL"
|
asnLimitPercentURL = "SWWAF_ASN_LIMIT_PERCENT_URL"
|
||||||
blocklistURLs = "SWW_BLOCKLIST_URLS"
|
blocklistURLs = "SWWAF_BLOCKLIST_URLS"
|
||||||
blocklistRefresh = "SWW_BLOCKLIST_REFRESH"
|
blocklistRefresh = "SWWAF_BLOCKLIST_REFRESH"
|
||||||
blocklistAction = "SWW_BLOCKLIST_ACTION"
|
blocklistAction = "SWWAF_BLOCKLIST_ACTION"
|
||||||
dnsblZones = "SWW_DNSBL_ZONES"
|
dnsblZones = "SWWAF_DNSBL_ZONES"
|
||||||
dnsblResolver = "SWW_DNSBL_RESOLVER"
|
dnsblResolver = "SWWAF_DNSBL_RESOLVER"
|
||||||
abuseIPDBKey = "SWW_ABUSEIPDB_KEY"
|
abuseIPDBKey = "SWWAF_ABUSEIPDB_KEY"
|
||||||
abuseIPDBMinScore = "SWW_ABUSEIPDB_MIN_SCORE"
|
abuseIPDBMinScore = "SWWAF_ABUSEIPDB_MIN_SCORE"
|
||||||
abuseIPDBDailyBudget = "SWW_ABUSEIPDB_DAILY_BUDGET"
|
abuseIPDBDailyBudget = "SWWAF_ABUSEIPDB_DAILY_BUDGET"
|
||||||
reputationAction = "SWW_REPUTATION_ACTION"
|
reputationAction = "SWWAF_REPUTATION_ACTION"
|
||||||
reputationCacheTTL = "SWW_REPUTATION_CACHE_TTL"
|
reputationCacheTTL = "SWWAF_REPUTATION_CACHE_TTL"
|
||||||
reputationTimeout = "SWW_REPUTATION_TIMEOUT"
|
reputationTimeout = "SWWAF_REPUTATION_TIMEOUT"
|
||||||
routeWatchURL = "SWW_ROUTEWATCH_URL"
|
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL"
|
||||||
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
|
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY"
|
||||||
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
|
banResponse = "SWWAF_BAN_RESPONSE"
|
||||||
banResponse = "SWW_BAN_RESPONSE"
|
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
||||||
limitBanDuration = "SWW_LIMIT_BAN_DURATION"
|
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
||||||
limitBanRepeatWindow = "SWW_LIMIT_BAN_REPEAT_WINDOW"
|
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
||||||
maxBanDuration = "SWW_MAX_BAN_DURATION"
|
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
||||||
attackBanDuration = "SWW_ATTACK_BAN_DURATION"
|
maxBans = "SWWAF_MAX_BANS"
|
||||||
maxBans = "SWW_MAX_BANS"
|
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
||||||
banScopeV4Prefix = "SWW_BAN_SCOPE_V4_PREFIX"
|
stateDir = "SWWAF_STATE_DIR"
|
||||||
stateDir = "SWW_STATE_DIR"
|
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY"
|
||||||
stateWriteDelay = "SWW_STATE_WRITE_DELAY"
|
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
|
||||||
stateCounterInterval = "SWW_STATE_COUNTER_INTERVAL"
|
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||||
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||||
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
|
metricsTopN = "SWWAF_METRICS_TOP_N"
|
||||||
metricsTopN = "SWW_METRICS_TOP_N"
|
instanceName = "SWWAF_INSTANCE_NAME"
|
||||||
instanceName = "SWW_INSTANCE_NAME"
|
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
||||||
logRequestHeaders = "SWW_LOG_REQUEST_HEADERS"
|
logLevel = "SWWAF_LOG_LEVEL"
|
||||||
logLevel = "SWW_LOG_LEVEL"
|
rulesDir = "SWWAF_RULES_DIR"
|
||||||
rulesDir = "SWW_RULES_DIR"
|
rulesEnabled = "SWWAF_RULES_ENABLED"
|
||||||
rulesEnabled = "SWW_RULES_ENABLED"
|
wafMode = "SWWAF_WAF_MODE"
|
||||||
wafMode = "SWW_WAF_MODE"
|
wafParanoiaLevel = "SWWAF_WAF_PARANOIA_LEVEL"
|
||||||
wafParanoiaLevel = "SWW_WAF_PARANOIA_LEVEL"
|
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD"
|
||||||
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
|
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
|
||||||
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
|
||||||
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
|
||||||
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
trapPaths = "SWWAF_TRAP_PATHS"
|
||||||
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
|
errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
|
||||||
trapPaths = "SWW_TRAP_PATHS"
|
logRemoteURL = "SWWAF_LOG_REMOTE_URL"
|
||||||
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
logRemoteTLSCAFile = "SWWAF_LOG_REMOTE_TLS_CA_FILE"
|
||||||
logRemoteURL = "SWW_LOG_REMOTE_URL"
|
logRemoteBuffer = "SWWAF_LOG_REMOTE_BUFFER"
|
||||||
logRemoteTLSCAFile = "SWW_LOG_REMOTE_TLS_CA_FILE"
|
logRemoteFacility = "SWWAF_LOG_REMOTE_FACILITY"
|
||||||
logRemoteBuffer = "SWW_LOG_REMOTE_BUFFER"
|
logRemoteAppName = "SWWAF_LOG_REMOTE_APP_NAME"
|
||||||
logRemoteFacility = "SWW_LOG_REMOTE_FACILITY"
|
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL"
|
||||||
logRemoteAppName = "SWW_LOG_REMOTE_APP_NAME"
|
alertWebhookHeaders = "SWWAF_ALERT_WEBHOOK_HEADERS"
|
||||||
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
|
alertSlackWebhookURL = "SWWAF_ALERT_SLACK_WEBHOOK_URL"
|
||||||
alertWebhookHeaders = "SWW_ALERT_WEBHOOK_HEADERS"
|
alertNtfyURL = "SWWAF_ALERT_NTFY_URL"
|
||||||
alertSlackWebhookURL = "SWW_ALERT_SLACK_WEBHOOK_URL"
|
alertNtfyToken = "SWWAF_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
|
||||||
alertNtfyURL = "SWW_ALERT_NTFY_URL"
|
alertEvents = "SWWAF_ALERT_EVENTS"
|
||||||
alertNtfyToken = "SWW_ALERT_NTFY_TOKEN" //nolint:gosec // the setting's name
|
alertCooldown = "SWWAF_ALERT_COOLDOWN"
|
||||||
alertEvents = "SWW_ALERT_EVENTS"
|
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
|
||||||
alertCooldown = "SWW_ALERT_COOLDOWN"
|
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX"
|
||||||
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
|
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX"
|
||||||
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
|
watchNets = "SWWAF_WATCH_NETS"
|
||||||
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
|
|
||||||
watchNets = "SWW_WATCH_NETS"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The anomaly thresholds: each of the prefixes below, which name a scope,
|
// The anomaly thresholds: each of the prefixes below, which name a scope,
|
||||||
// followed by each of the four ends.
|
// followed by each of the four ends.
|
||||||
const (
|
const (
|
||||||
anomalyClient = "SWW_ANOMALY_CLIENT_"
|
anomalyClient = "SWWAF_ANOMALY_CLIENT_"
|
||||||
anomalyNet = "SWW_ANOMALY_NET_"
|
anomalyNet = "SWWAF_ANOMALY_NET_"
|
||||||
anomalyASN = "SWW_ANOMALY_ASN_"
|
anomalyASN = "SWWAF_ANOMALY_ASN_"
|
||||||
anomalyTotal = "SWW_ANOMALY_TOTAL_"
|
anomalyTotal = "SWWAF_ANOMALY_TOTAL_"
|
||||||
watch = "SWW_WATCH_"
|
watch = "SWWAF_WATCH_"
|
||||||
|
|
||||||
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
||||||
requestsPerHour = "REQUESTS_PER_HOUR"
|
requestsPerHour = "REQUESTS_PER_HOUR"
|
||||||
@@ -157,21 +155,21 @@ func loggedAnomalyDefaults() map[string]string {
|
|||||||
return logged
|
return logged
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultAlertEvents is the default of SWW_ALERT_EVENTS, and
|
// defaultAlertEvents is the default of SWWAF_ALERT_EVENTS, and
|
||||||
// defaultAlertCooldown that of SWW_ALERT_COOLDOWN.
|
// defaultAlertCooldown that of SWWAF_ALERT_COOLDOWN.
|
||||||
const (
|
const (
|
||||||
defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," +
|
defaultAlertEvents = "ban,permanent_ban,waf_block,anomaly,reputation_hit," +
|
||||||
"source_failure,file_error"
|
"source_failure,file_error"
|
||||||
defaultAlertCooldown = "15m"
|
defaultAlertCooldown = "15m"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultReputationCacheTTL is the default of SWW_REPUTATION_CACHE_TTL.
|
// defaultReputationCacheTTL is the default of SWWAF_REPUTATION_CACHE_TTL.
|
||||||
const defaultReputationCacheTTL = "24h"
|
const defaultReputationCacheTTL = "24h"
|
||||||
|
|
||||||
// defaultWAFDisabledRules is the default of SWW_WAF_DISABLED_RULES.
|
// defaultWAFDisabledRules is the default of SWWAF_WAF_DISABLED_RULES.
|
||||||
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
|
const defaultWAFDisabledRules = "920340,920420,920440,920640,930130,930140"
|
||||||
|
|
||||||
// defaultLogRequestHeaders is the default of SWW_LOG_REQUEST_HEADERS.
|
// defaultLogRequestHeaders is the default of SWWAF_LOG_REQUEST_HEADERS.
|
||||||
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
|
const defaultLogRequestHeaders = "accept,accept-language,accept-encoding," +
|
||||||
"content-type,origin,range"
|
"content-type,origin,range"
|
||||||
|
|
||||||
@@ -196,8 +194,8 @@ const (
|
|||||||
otherToken = "fedcba9876543210fedcba9876543210"
|
otherToken = "fedcba9876543210fedcba9876543210"
|
||||||
)
|
)
|
||||||
|
|
||||||
// instance is an SWW_INSTANCE_NAME that is a valid app name too, and
|
// instance is an SWWAF_INSTANCE_NAME that is a valid app name too, and
|
||||||
// remoteURL an SWW_LOG_REMOTE_URL, for the tests that send the lines.
|
// remoteURL an SWWAF_LOG_REMOTE_URL, for the tests that send the lines.
|
||||||
const (
|
const (
|
||||||
instance = "fsn1app1/blog"
|
instance = "fsn1app1/blog"
|
||||||
remoteURL = "syslog+udp://192.0.2.1:514"
|
remoteURL = "syslog+udp://192.0.2.1:514"
|
||||||
@@ -209,10 +207,7 @@ const off = "off"
|
|||||||
// enabled is true, as a setting's value.
|
// enabled is true, as a setting's value.
|
||||||
const enabled = "true"
|
const enabled = "true"
|
||||||
|
|
||||||
// disabled is false, as a setting's value.
|
// defaultLookupSource is the default of SWWAF_LOOKUP_SOURCE, and
|
||||||
const disabled = "false"
|
|
||||||
|
|
||||||
// defaultLookupSource is the default of SWW_LOOKUP_SOURCE, and
|
|
||||||
// fileSource the source that is the lookup database.
|
// fileSource the source that is the lookup database.
|
||||||
const (
|
const (
|
||||||
defaultLookupSource = "geojs"
|
defaultLookupSource = "geojs"
|
||||||
@@ -259,12 +254,12 @@ func TestDefaults(t *testing.T) {
|
|||||||
AttackBanDuration: 7 * 24 * time.Hour,
|
AttackBanDuration: 7 * 24 * time.Hour,
|
||||||
MaxBans: 5000,
|
MaxBans: 5000,
|
||||||
BanScopeV4Prefix: 32,
|
BanScopeV4Prefix: 32,
|
||||||
StateDir: "/var/lib/sww",
|
StateDir: "/var/lib/smallwebwaf",
|
||||||
StateWriteDelay: 10 * time.Second,
|
StateWriteDelay: 10 * time.Second,
|
||||||
StateCounterInterval: 15 * time.Minute,
|
StateCounterInterval: 15 * time.Minute,
|
||||||
MetricsToken: "",
|
MetricsToken: "",
|
||||||
MetricsTopN: 50,
|
MetricsTopN: 50,
|
||||||
RulesDir: "/etc/sww/rules.d",
|
RulesDir: "/etc/smallwebwaf/rules.d",
|
||||||
RulesEnabled: true,
|
RulesEnabled: true,
|
||||||
})
|
})
|
||||||
wantLookupSettings(t, cfg, config.Config{
|
wantLookupSettings(t, cfg, config.Config{
|
||||||
@@ -278,13 +273,9 @@ func TestDefaults(t *testing.T) {
|
|||||||
wantNetblocks(t, cfg.AllowNets)
|
wantNetblocks(t, cfg.AllowNets)
|
||||||
wantNetblocks(t, cfg.RateLimitExemptNets)
|
wantNetblocks(t, cfg.RateLimitExemptNets)
|
||||||
wantNetblocks(t, cfg.DenyNets)
|
wantNetblocks(t, cfg.DenyNets)
|
||||||
|
wantCountries(t, deniedCountries, cfg.DeniedCountries)
|
||||||
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries)
|
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries)
|
||||||
|
|
||||||
if len(cfg.TagLists) != 0 || len(cfg.TagDeny) != 0 || len(cfg.TagLimitPercent) != 0 {
|
|
||||||
t.Errorf("tag settings %v, %v and %v by default, want all empty",
|
|
||||||
cfg.TagLists, cfg.TagDeny, cfg.TagLimitPercent)
|
|
||||||
}
|
|
||||||
|
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
if err != nil || hostname == "" || cfg.InstanceName != hostname {
|
if err != nil || hostname == "" || cfg.InstanceName != hostname {
|
||||||
t.Errorf("%s is %q, want the host's name %q (%v)", instanceName,
|
t.Errorf("%s is %q, want the host's name %q (%v)", instanceName,
|
||||||
@@ -335,6 +326,7 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
rateLimitPerMinute: "60",
|
rateLimitPerMinute: "60",
|
||||||
rateLimitPerHour: "600",
|
rateLimitPerHour: "600",
|
||||||
rateLimitPerDay: "6000",
|
rateLimitPerDay: "6000",
|
||||||
|
deniedCountries: "cn, RU,kp,Xk",
|
||||||
allowedCountries: "de",
|
allowedCountries: "de",
|
||||||
banResponse: "429",
|
banResponse: "429",
|
||||||
limitBanDuration: "15m",
|
limitBanDuration: "15m",
|
||||||
@@ -349,7 +341,7 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
metricsTopN: "10",
|
metricsTopN: "10",
|
||||||
rulesDir: "/srv/waf-rules",
|
rulesDir: "/srv/waf-rules",
|
||||||
rulesEnabled: disabled,
|
rulesEnabled: "false",
|
||||||
})
|
})
|
||||||
|
|
||||||
wantSettings(t, cfg, config.Config{
|
wantSettings(t, cfg, config.Config{
|
||||||
@@ -377,6 +369,7 @@ func TestValuesAsSet(t *testing.T) {
|
|||||||
wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32")
|
wantNetblocks(t, cfg.AllowNets, "192.0.2.7/32")
|
||||||
wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32")
|
wantNetblocks(t, cfg.RateLimitExemptNets, "2001:db8::/48", "10.9.8.7/32")
|
||||||
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
|
wantNetblocks(t, cfg.DenyNets, "198.51.100.0/24")
|
||||||
|
wantCountries(t, deniedCountries, cfg.DeniedCountries, "CN", "RU", "KP", "XK")
|
||||||
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
|
wantCountries(t, allowedCountries, cfg.ExclusivelyAllowedCountries, "DE")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,14 +539,13 @@ func TestCoreRuleSetSettings(t *testing.T) {
|
|||||||
environment{
|
environment{
|
||||||
wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10",
|
wafMode: config.WAFModeDetect, wafParanoiaLevel: "4", wafAnomalyThreshold: "10",
|
||||||
wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/",
|
wafDisabledRules: "942100, 920350", wafExemptPaths: "/api/, /static/",
|
||||||
wafBodyLimit: "32K", exclusionsFile: "/etc/sww/crs-exclusions.conf",
|
wafBodyLimit: "32K",
|
||||||
},
|
},
|
||||||
config.Config{
|
config.Config{
|
||||||
WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10,
|
WAFMode: config.WAFModeDetect, WAFParanoiaLevel: 4, WAFAnomalyThreshold: 10,
|
||||||
WAFDisabledRules: []int{942100, 920350},
|
WAFDisabledRules: []int{942100, 920350},
|
||||||
WAFExemptPaths: []string{"/api/", "/static/"},
|
WAFExemptPaths: []string{"/api/", "/static/"},
|
||||||
WAFBodyLimit: 32 << 10,
|
WAFBodyLimit: 32 << 10,
|
||||||
CoreRuleSetExclusionsFile: "/etc/sww/crs-exclusions.conf",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -573,8 +565,7 @@ func TestCoreRuleSetSettings(t *testing.T) {
|
|||||||
WAFMode: cfg.WAFMode, WAFParanoiaLevel: cfg.WAFParanoiaLevel,
|
WAFMode: cfg.WAFMode, WAFParanoiaLevel: cfg.WAFParanoiaLevel,
|
||||||
WAFAnomalyThreshold: cfg.WAFAnomalyThreshold,
|
WAFAnomalyThreshold: cfg.WAFAnomalyThreshold,
|
||||||
WAFDisabledRules: cfg.WAFDisabledRules, WAFExemptPaths: cfg.WAFExemptPaths,
|
WAFDisabledRules: cfg.WAFDisabledRules, WAFExemptPaths: cfg.WAFExemptPaths,
|
||||||
WAFBodyLimit: cfg.WAFBodyLimit,
|
WAFBodyLimit: cfg.WAFBodyLimit,
|
||||||
CoreRuleSetExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(got, tc.want) {
|
if !reflect.DeepEqual(got, tc.want) {
|
||||||
t.Errorf("%v gave\n%+v\nwant\n%+v", tc.env, got, tc.want)
|
t.Errorf("%v gave\n%+v\nwant\n%+v", tc.env, got, tc.want)
|
||||||
@@ -597,7 +588,7 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
|
|||||||
const (
|
const (
|
||||||
notParanoiaLevel = " is not a paranoia level, from 1 to 4"
|
notParanoiaLevel = " is not a paranoia level, from 1 to 4"
|
||||||
setupRule = " is from 900000 to 900999, the ids of the rules that set " +
|
setupRule = " is from 900000 to 900999, the ids of the rules that set " +
|
||||||
"the Core Rule Set up and of sww's own, which cannot be switched off"
|
"the Core Rule Set up and of smallwebwaf's own, which cannot be switched off"
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct{ name, value, want string }{
|
for _, tc := range []struct{ name, value, want string }{
|
||||||
@@ -636,11 +627,10 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
|
|||||||
wafBodyLimit, "128KB",
|
wafBodyLimit, "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`,
|
||||||
},
|
},
|
||||||
{wafBodyLimit, "65K", `"65K" is more than 64K, the most discriminator ` +
|
{wafBodyLimit, "65K", `"65K" is more than 64K, the most servewaf gets of a body`},
|
||||||
`gets of a body`},
|
|
||||||
{
|
{
|
||||||
wafBodyLimit, "65537",
|
wafBodyLimit, "65537",
|
||||||
`"65537" is more than 64K, the most discriminator gets of a body`,
|
`"65537" is more than 64K, the most servewaf gets of a body`,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
|
||||||
@@ -866,7 +856,7 @@ func TestAlertSettingsAsSet(t *testing.T) {
|
|||||||
const (
|
const (
|
||||||
webhook = "https://alerts.example:8443/hooks/waf?team=ops"
|
webhook = "https://alerts.example:8443/hooks/waf?team=ops"
|
||||||
slack = "https://hooks.slack.example/services/T0123/B4567/abcdef"
|
slack = "https://hooks.slack.example/services/T0123/B4567/abcdef"
|
||||||
ntfy = "https://ntfy.example/sww-alerts"
|
ntfy = "https://ntfy.example/smallwebwaf-alerts"
|
||||||
token = "tk_0123456789abcdefghijklmnopq"
|
token = "tk_0123456789abcdefghijklmnopq"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -914,17 +904,17 @@ func TestInvalidAlertSettingStopsTheStart(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
wantStartStopped(t, []struct{ name, value string }{
|
wantStartStopped(t, []struct{ name, value string }{
|
||||||
{alertWebhookURL, "alerts.example/sww"},
|
{alertWebhookURL, "alerts.example/smallwebwaf"},
|
||||||
{alertWebhookURL, "ftp://alerts.example/"},
|
{alertWebhookURL, "ftp://alerts.example/"},
|
||||||
{alertWebhookURL, "https:///sww"},
|
{alertWebhookURL, "https:///smallwebwaf"},
|
||||||
{alertWebhookURL, "https://user:password@alerts.example/"},
|
{alertWebhookURL, "https://user:password@alerts.example/"},
|
||||||
{alertWebhookURL, "https://alerts.example/#top"},
|
{alertWebhookURL, "https://alerts.example/#top"},
|
||||||
{alertWebhookURL, "https://alerts.example:0/"},
|
{alertWebhookURL, "https://alerts.example:0/"},
|
||||||
{alertWebhookURL, "https://alerts.example:65536/"},
|
{alertWebhookURL, "https://alerts.example:65536/"},
|
||||||
{alertSlackWebhookURL, "hooks.slack.example/services/T0123"},
|
{alertSlackWebhookURL, "hooks.slack.example/services/T0123"},
|
||||||
{alertSlackWebhookURL, "https://user:password@hooks.slack.example/"},
|
{alertSlackWebhookURL, "https://user:password@hooks.slack.example/"},
|
||||||
{alertNtfyURL, "ntfy://ntfy.example/sww-alerts"},
|
{alertNtfyURL, "ntfy://ntfy.example/smallwebwaf-alerts"},
|
||||||
{alertNtfyURL, "https://ntfy.example/sww-alerts#top"},
|
{alertNtfyURL, "https://ntfy.example/smallwebwaf-alerts#top"},
|
||||||
{alertWebhookHeaders, "Authorization"},
|
{alertWebhookHeaders, "Authorization"},
|
||||||
{alertWebhookHeaders, "X Team:ops"},
|
{alertWebhookHeaders, "X Team:ops"},
|
||||||
{alertWebhookHeaders, ":ops"},
|
{alertWebhookHeaders, ":ops"},
|
||||||
@@ -1004,7 +994,7 @@ func TestWebhookURLIsLoggedWithoutItsPathOrQueryAndNeverShown(t *testing.T) {
|
|||||||
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv)
|
_, err := config.FromEnvironment(environment{alertWebhookURL: value}.lookupEnv)
|
||||||
|
|
||||||
want := alertWebhookURL + ": is not an http or https URL without a user or " +
|
want := alertWebhookURL + ": is not an http or https URL without a user or " +
|
||||||
"a fragment, such as https://alerts.example/sww"
|
"a fragment, such as https://alerts.example/smallwebwaf"
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
t.Errorf("error %v, want %s", err, want)
|
t.Errorf("error %v, want %s", err, want)
|
||||||
}
|
}
|
||||||
@@ -1018,7 +1008,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
|
|||||||
|
|
||||||
cfg := fromEnvironment(t, environment{
|
cfg := fromEnvironment(t, environment{
|
||||||
alertSlackWebhookURL: "https://hooks.slack.example/services/T0123/B4567/abcdef",
|
alertSlackWebhookURL: "https://hooks.slack.example/services/T0123/B4567/abcdef",
|
||||||
alertNtfyURL: "https://ntfy.example/sww-alerts",
|
alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts",
|
||||||
alertNtfyToken: token,
|
alertNtfyToken: token,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1037,7 +1027,7 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, secret := range []string{"T0123", "sww-alerts", token} {
|
for _, secret := range []string{"T0123", "smallwebwaf-alerts", token} {
|
||||||
if strings.Contains(logged, secret) {
|
if strings.Contains(logged, secret) {
|
||||||
t.Errorf("%s in the settings logged: %s", secret, logged)
|
t.Errorf("%s in the settings logged: %s", secret, logged)
|
||||||
}
|
}
|
||||||
@@ -1068,7 +1058,7 @@ func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testin
|
|||||||
const name = "fsn1app1\r"
|
const name = "fsn1app1\r"
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
instanceName: name, alertNtfyURL: "https://ntfy.example/sww-alerts",
|
instanceName: name, alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts",
|
||||||
}.lookupEnv)
|
}.lookupEnv)
|
||||||
|
|
||||||
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
|
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
|
||||||
@@ -1103,15 +1093,15 @@ func TestInstanceNameNotUTF8StopsTheStart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCodeInAllowedCountriesAndTagDenyStopsTheStart(t *testing.T) {
|
func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{
|
_, err := config.FromEnvironment(environment{
|
||||||
tagDeny: "country=cn,country=ru",
|
deniedCountries: "cn,ru",
|
||||||
allowedCountries: "de,RU",
|
allowedCountries: "de,RU",
|
||||||
}.lookupEnv)
|
}.lookupEnv)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("ru in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES and SWW_TAG_DENY was accepted")
|
t.Fatal("ru on both country lists was accepted")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(err.Error(), allowedCountries+": ") ||
|
if !strings.HasPrefix(err.Error(), allowedCountries+": ") ||
|
||||||
@@ -1182,11 +1172,12 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for name, value := range map[string]string{
|
for name, value := range map[string]string{
|
||||||
|
deniedCountries: "kp",
|
||||||
allowedCountries: "de",
|
allowedCountries: "de",
|
||||||
addLookupHeaders: enabled,
|
addLookupHeaders: enabled,
|
||||||
tagDeny: "country=KP",
|
|
||||||
tagLimitPercent: "asn=64496:50",
|
|
||||||
asnLimitPercentURL: asnURL,
|
asnLimitPercentURL: asnURL,
|
||||||
|
asnLimitPercent: "AS64496:50",
|
||||||
|
countryLimitPercent: "cn:25",
|
||||||
asnBytesPercent: "AS64496:50",
|
asnBytesPercent: "AS64496:50",
|
||||||
countryBytesPercent: "cn:25",
|
countryBytesPercent: "cn:25",
|
||||||
unknownLimitPercent: "99",
|
unknownLimitPercent: "99",
|
||||||
@@ -1209,13 +1200,12 @@ func TestSettingNeedingLookupsStopsTheStartWhileTheyAreOff(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set empty, the lists need nothing looked up, and nor does
|
// Set empty, the lists need nothing looked up, and nor does
|
||||||
// SWW_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, a tag the
|
// SWWAF_UNKNOWN_LIMIT_PERCENT at 100, which lowers no limit, an anomaly
|
||||||
// lookup does not give, an anomaly threshold per AS number that is off,
|
// threshold per AS number that is off, any other anomaly threshold, or
|
||||||
// any other anomaly threshold, or a blocklist.
|
// a blocklist.
|
||||||
env := environment{
|
env := environment{
|
||||||
lookupSource: off, allowedCountries: "", asnBytesPercent: "",
|
lookupSource: off, deniedCountries: "", allowedCountries: "",
|
||||||
tagLists: "torexit=https://example.com/tor.txt,vpn=https://example.com/vpn.txt",
|
asnLimitPercent: "", countryLimitPercent: "", asnBytesPercent: "",
|
||||||
tagDeny: torExitTag, tagLimitPercent: "vpn:50",
|
|
||||||
countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "",
|
countryBytesPercent: "", unknownLimitPercent: "100", asnLimitPercentURL: "",
|
||||||
blocklistURLs: dropURL,
|
blocklistURLs: dropURL,
|
||||||
}
|
}
|
||||||
@@ -1361,251 +1351,34 @@ func TestInvalidAnomalySettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// torExitTag, zenTag and exploitTag are tags these tests name.
|
|
||||||
const (
|
|
||||||
torExitTag = "torexit"
|
|
||||||
zenTag = "dnsbl=zen.example"
|
|
||||||
exploitTag = "exploitsignature"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTagSettingsAsSet(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const torURL = "https://example.com/tor.txt"
|
|
||||||
|
|
||||||
// Names and tags in either case, an AS number with leading zeros, and a
|
|
||||||
// name holding a =.
|
|
||||||
cfg := fromEnvironment(t, environment{
|
|
||||||
tagLists: "TorExit=" + torURL +
|
|
||||||
", vpn=mullvad=https://example.com/mullvad.txt",
|
|
||||||
dnsblZones: "Zen.example",
|
|
||||||
tagDeny: "TorExit, Country=kp, asn=014061",
|
|
||||||
tagLimitPercent: "vpn=mullvad :50, asn=014061:25, COUNTRY=cn:1, dnsbl=zen.example:99",
|
|
||||||
})
|
|
||||||
|
|
||||||
wantLists := []config.TagList{
|
|
||||||
{Name: torExitTag, URL: torURL},
|
|
||||||
{Name: "vpn=mullvad", URL: "https://example.com/mullvad.txt"},
|
|
||||||
}
|
|
||||||
if !slices.Equal(cfg.TagLists, wantLists) {
|
|
||||||
t.Errorf("%s gave %+v, want %+v", tagLists, cfg.TagLists, wantLists)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantDeny := []string{torExitTag, "country=KP", "asn=14061"}
|
|
||||||
if !slices.Equal(cfg.TagDeny, wantDeny) {
|
|
||||||
t.Errorf("%s gave %v, want %v", tagDeny, cfg.TagDeny, wantDeny)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantPercents := map[string]int64{
|
|
||||||
"vpn=mullvad": 50, "asn=14061": 25, "country=CN": 1, zenTag: 99,
|
|
||||||
}
|
|
||||||
if !maps.Equal(cfg.TagLimitPercent, wantPercents) {
|
|
||||||
t.Errorf("%s gave %v, want %v", tagLimitPercent, cfg.TagLimitPercent, wantPercents)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouteWatchURLAndPrefixTags(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const rw = "https://routewatch.example"
|
|
||||||
|
|
||||||
if cfg := fromEnvironment(t, environment{}); cfg.RouteWatchURL != "" {
|
|
||||||
t.Errorf("default %s is %q, want unset", routeWatchURL, cfg.RouteWatchURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := fromEnvironment(t, environment{
|
|
||||||
routeWatchURL: rw, tagDeny: "prefix=2001:DB8::/32",
|
|
||||||
tagLimitPercent: "prefix=192.0.2.0/24:50",
|
|
||||||
})
|
|
||||||
wantDeny := []string{"prefix=2001:db8::/32"}
|
|
||||||
|
|
||||||
if cfg.RouteWatchURL != rw || !slices.Equal(cfg.TagDeny, wantDeny) ||
|
|
||||||
cfg.TagLimitPercent["prefix=192.0.2.0/24"] != 50 {
|
|
||||||
t.Errorf("gave %q, %v, %v", cfg.RouteWatchURL, cfg.TagDeny, cfg.TagLimitPercent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// routewatch gives asn= tags too, so an asn= tag needs no lookups while it is set.
|
|
||||||
fromEnvironment(t, environment{
|
|
||||||
lookupSource: off, routeWatchURL: rw, tagDeny: "asn=64496",
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, stopped := range []struct {
|
|
||||||
env environment
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{environment{routeWatchURL: "ftp://routewatch.example"}, routeWatchURL},
|
|
||||||
{environment{routeWatchURL: "routewatch.example"}, routeWatchURL},
|
|
||||||
{environment{tagDeny: "prefix=192.0.2.0/24"}, tagDeny},
|
|
||||||
{environment{routeWatchURL: rw, tagDeny: "prefix=nowhere"}, tagDeny},
|
|
||||||
{environment{routeWatchURL: rw, tagDeny: "prefix=192.0.2.5/24"}, tagDeny},
|
|
||||||
{
|
|
||||||
environment{routeWatchURL: rw, tagLimitPercent: "prefix=192.0.2.0/33:50"},
|
|
||||||
tagLimitPercent,
|
|
||||||
},
|
|
||||||
{environment{lookupSource: off, routeWatchURL: rw, tagDeny: "country=DE"}, tagDeny},
|
|
||||||
{environment{lookupSource: off, tagDeny: "asn=64496"}, tagDeny},
|
|
||||||
} {
|
|
||||||
_, err := config.FromEnvironment(stopped.env.lookupEnv)
|
|
||||||
if err == nil || !strings.HasPrefix(err.Error(), stopped.name+": ") {
|
|
||||||
t.Errorf("%v gave the error %v, want one naming %s", stopped.env, err, stopped.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
badURL := environment{routeWatchURL: "ftp://routewatch.example"}
|
|
||||||
_, err := config.FromEnvironment(badURL.lookupEnv)
|
|
||||||
|
|
||||||
want := routeWatchURL + `: "ftp://routewatch.example" is not an http or https URL ` +
|
|
||||||
"without a user or a fragment, such as https://routewatch.example"
|
|
||||||
if err == nil || err.Error() != want {
|
|
||||||
t.Errorf("error %v, want %s", err, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagThatNoRequestCanGetIsToldApartFromOneItCan(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
list = "torexit=https://example.com/tor.txt"
|
|
||||||
key = "abc123"
|
|
||||||
zone = key + ".xbl.dq.spamhaus.net"
|
|
||||||
masked = "********.xbl.dq.spamhaus.net"
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
env environment
|
|
||||||
want string // "" is accepted
|
|
||||||
}{
|
|
||||||
{"a list's name", environment{tagLists: list, tagDeny: torExitTag}, ""},
|
|
||||||
{
|
|
||||||
"a mistyped list's name", environment{tagLists: list, tagDeny: "torexti"},
|
|
||||||
`"torexti"`,
|
|
||||||
},
|
|
||||||
{"a masked zone", environment{dnsblZones: zone, tagDeny: "dnsbl=" + masked}, ""},
|
|
||||||
{
|
|
||||||
"a zone with its key", environment{dnsblZones: zone, tagDeny: "dnsbl=" + zone},
|
|
||||||
masked,
|
|
||||||
},
|
|
||||||
{"another zone", environment{dnsblZones: zone, tagDeny: zenTag}, zenTag},
|
|
||||||
{
|
|
||||||
"abuseipdb with a key",
|
|
||||||
environment{abuseIPDBKey: "k", tagLimitPercent: "abuseipdb:50"}, "",
|
|
||||||
},
|
|
||||||
{"crowdsec, though a list has its name", environment{
|
|
||||||
tagLists: "crowdsec=https://example.com/c.txt", tagDeny: "crowdsec",
|
|
||||||
}, `"crowdsec" cannot be used`},
|
|
||||||
{exploitTag, environment{tagDeny: exploitTag}, ""},
|
|
||||||
{
|
|
||||||
exploitTag + " with rule files off",
|
|
||||||
environment{tagDeny: exploitTag, rulesEnabled: disabled}, "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
exploitTag + " with the Core Rule Set off",
|
|
||||||
environment{tagDeny: exploitTag, wafMode: off}, "",
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := config.FromEnvironment(tc.env.lookupEnv)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case tc.want == "" && err != nil:
|
|
||||||
t.Errorf("error %v, want none", err)
|
|
||||||
case tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)):
|
|
||||||
t.Errorf("error %v, want one holding %s", err, tc.want)
|
|
||||||
case err != nil && strings.Contains(err.Error(), key):
|
|
||||||
t.Errorf("error %v shows the zone's key", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorOfATagSettingLeavesOutAZoneKey(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
tag = "dnsbl=" + spamhaus
|
|
||||||
emptyItem = "has an empty item in its list"
|
|
||||||
twice = `"` + spamhausMasked + `" is listed twice`
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, tc := range []struct{ name, value, want string }{
|
|
||||||
{tagDeny, tag + "," + tag, twice},
|
|
||||||
{tagDeny, tag + ",,", emptyItem},
|
|
||||||
{
|
|
||||||
tagLimitPercent, tag,
|
|
||||||
`"` + spamhausMasked + `" is not a tag, : and a percentage, ` +
|
|
||||||
"such as vpn:50 or asn=14061:25",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tagLimitPercent, "50:" + tag,
|
|
||||||
`"` + spamhausMasked + `" is not a percentage from 1 to 99`,
|
|
||||||
},
|
|
||||||
{tagLimitPercent, tag + ":50," + tag + " :25", twice},
|
|
||||||
{tagLimitPercent, tag + ":50,,", emptyItem},
|
|
||||||
} {
|
|
||||||
_, err := config.FromEnvironment(environment{tc.name: tc.value}.lookupEnv)
|
|
||||||
|
|
||||||
want := tc.name + ": " + tc.want
|
|
||||||
if err == nil || err.Error() != want {
|
|
||||||
t.Errorf("%s=%s gave the error %v, want %s", tc.name, tc.value, err, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagListURLFetchedAsAnotherListStopsTheStart(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const lapi = "http://172.17.0.1:8080"
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name, listURL string
|
|
||||||
env environment
|
|
||||||
}{
|
|
||||||
{"a blocklist", dropURL, environment{blocklistURLs: dropURL}},
|
|
||||||
{"the file of AS:percent lines", asnURL, environment{asnLimitPercentURL: asnURL}},
|
|
||||||
{
|
|
||||||
"the CrowdSec decision list", lapi + "/v1/decisions",
|
|
||||||
environment{"SWW_CROWDSEC_LAPI_URL": lapi, "SWW_CROWDSEC_LAPI_KEY": "key"},
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tc.env[tagLists] = "dup=" + tc.listURL
|
|
||||||
|
|
||||||
want := tagLists + `: "` + tc.listURL + `" is in SWW_BLOCKLIST_URLS, is ` +
|
|
||||||
"SWW_ASN_LIMIT_PERCENT_URL or is the CrowdSec decision list too"
|
|
||||||
|
|
||||||
_, err := config.FromEnvironment(tc.env.lookupEnv)
|
|
||||||
if err == nil || err.Error() != want {
|
|
||||||
t.Errorf("error %v, want %s", err, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBiasedThresholdsAsSet(t *testing.T) {
|
func TestBiasedThresholdsAsSet(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg := fromEnvironment(t, environment{})
|
cfg := fromEnvironment(t, environment{})
|
||||||
if len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
|
if len(cfg.ASNLimitPercent) != 0 || len(cfg.CountryLimitPercent) != 0 ||
|
||||||
|
len(cfg.ASNBytesPercent) != 0 || len(cfg.CountryBytesPercent) != 0 ||
|
||||||
cfg.UnknownLimitPercent != 100 {
|
cfg.UnknownLimitPercent != 100 {
|
||||||
t.Errorf("biased thresholds %v, %v and %d by default, "+
|
t.Errorf("biased thresholds %v, %v, %v, %v and %d by default, "+
|
||||||
"want two empty lists and 100",
|
"want four empty lists and 100", cfg.ASNLimitPercent, cfg.CountryLimitPercent,
|
||||||
cfg.ASNBytesPercent, cfg.CountryBytesPercent, cfg.UnknownLimitPercent)
|
cfg.ASNBytesPercent, cfg.CountryBytesPercent, cfg.UnknownLimitPercent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AS numbers and countries in either case, an AS number with leading
|
// AS numbers and countries in either case, an AS number with leading
|
||||||
// zeros, 0 and 100.
|
// zeros, 0 and 100.
|
||||||
cfg = fromEnvironment(t, environment{
|
cfg = fromEnvironment(t, environment{
|
||||||
|
asnLimitPercent: "AS14061:50, as16276:0,AS045102:100",
|
||||||
|
countryLimitPercent: "cn:25,RU:50",
|
||||||
asnBytesPercent: "as16276:75",
|
asnBytesPercent: "as16276:75",
|
||||||
countryBytesPercent: "ru:10",
|
countryBytesPercent: "ru:10",
|
||||||
unknownLimitPercent: "0",
|
unknownLimitPercent: "0",
|
||||||
})
|
})
|
||||||
|
|
||||||
for name, tc := range map[string]struct{ got, want map[string]int64 }{
|
for name, tc := range map[string]struct{ got, want map[string]int64 }{
|
||||||
|
asnLimitPercent: {
|
||||||
|
cfg.ASNLimitPercent,
|
||||||
|
map[string]int64{"AS14061": 50, "AS16276": 0, "AS45102": 100},
|
||||||
|
},
|
||||||
|
countryLimitPercent: {cfg.CountryLimitPercent, map[string]int64{"CN": 25, "RU": 50}},
|
||||||
asnBytesPercent: {cfg.ASNBytesPercent, map[string]int64{"AS16276": 75}},
|
asnBytesPercent: {cfg.ASNBytesPercent, map[string]int64{"AS16276": 75}},
|
||||||
countryBytesPercent: {cfg.CountryBytesPercent, map[string]int64{"RU": 10}},
|
countryBytesPercent: {cfg.CountryBytesPercent, map[string]int64{"RU": 10}},
|
||||||
} {
|
} {
|
||||||
@@ -1629,16 +1402,16 @@ func TestInvalidBiasedThresholdStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct{ name, value, want string }{
|
for _, tc := range []struct{ name, value, want string }{
|
||||||
{asnBytesPercent, "14061:50", `"14061"` + notASN},
|
{asnLimitPercent, "14061:50", `"14061"` + notASN},
|
||||||
{asnBytesPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
|
{asnLimitPercent, "AS4294967296:50", `"AS4294967296"` + notASN},
|
||||||
{asnBytesPercent, "AS14061", `"AS14061"` + notItem},
|
{asnLimitPercent, "AS14061", `"AS14061"` + notItem},
|
||||||
{asnBytesPercent, "AS14061:101", `"101"` + notPercent},
|
{asnLimitPercent, "AS14061:101", `"101"` + notPercent},
|
||||||
{asnBytesPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
|
{asnLimitPercent, "AS14061:50,as14061:25", `"as14061" is listed twice`},
|
||||||
{
|
{
|
||||||
countryBytesPercent, "nk:25",
|
countryLimitPercent, "nk:25",
|
||||||
`"nk" is not a two-letter country code such as de or kp`,
|
`"nk" is not a two-letter country code such as de or kp`,
|
||||||
},
|
},
|
||||||
{countryBytesPercent, "cn:25,CN:50", `"CN" is listed twice`},
|
{countryLimitPercent, "cn:25,CN:50", `"CN" is listed twice`},
|
||||||
{asnBytesPercent, "AS14061:-1", `"-1"` + notPercent},
|
{asnBytesPercent, "AS14061:-1", `"-1"` + notPercent},
|
||||||
{countryBytesPercent, "cn:50%", `"50%"` + notPercent},
|
{countryBytesPercent, "cn:50%", `"50%"` + notPercent},
|
||||||
{unknownLimitPercent, "101", `"101"` + notPercent},
|
{unknownLimitPercent, "101", `"101"` + notPercent},
|
||||||
@@ -1665,7 +1438,7 @@ const (
|
|||||||
asnURL = "https://lists.example/asn.txt"
|
asnURL = "https://lists.example/asn.txt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The actions of SWW_BLOCKLIST_ACTION, as Config gives them.
|
// The actions of SWWAF_BLOCKLIST_ACTION, as Config gives them.
|
||||||
const (
|
const (
|
||||||
actionDeny = "deny"
|
actionDeny = "deny"
|
||||||
actionLimit = "limit"
|
actionLimit = "limit"
|
||||||
@@ -1761,7 +1534,7 @@ func TestASNLimitPercentURLThatIsABlocklistStopsTheStart(t *testing.T) {
|
|||||||
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
|
blocklistURLs: dropURL + "," + asnURL, asnLimitPercentURL: asnURL,
|
||||||
}.lookupEnv)
|
}.lookupEnv)
|
||||||
|
|
||||||
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWW_BLOCKLIST_URLS too`
|
want := asnLimitPercentURL + `: "` + asnURL + `" is in SWWAF_BLOCKLIST_URLS too`
|
||||||
if err == nil || err.Error() != want {
|
if err == nil || err.Error() != want {
|
||||||
t.Errorf("error %v, want %s", err, want)
|
t.Errorf("error %v, want %s", err, want)
|
||||||
}
|
}
|
||||||
@@ -2064,7 +1837,7 @@ func TestInvalidCrowdSecSettingStopsTheStartSayingWhatIsWrong(t *testing.T) {
|
|||||||
notLAPIURL = " is not an http or https URL without a user or a fragment, " +
|
notLAPIURL = " is not an http or https URL without a user or a fragment, " +
|
||||||
"such as http://172.17.0.1:8080"
|
"such as http://172.17.0.1:8080"
|
||||||
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
|
anotherList = `gives the decision list "` + lapi + `/v1/decisions", which is in ` +
|
||||||
`SWW_BLOCKLIST_URLS or is SWW_ASN_LIMIT_PERCENT_URL too`
|
`SWWAF_BLOCKLIST_URLS or is SWWAF_ASN_LIMIT_PERCENT_URL too`
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -2226,37 +1999,23 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
|||||||
{lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""},
|
{lookupSource, "ipinfo"}, {lookupSource, "GeoJS"}, {lookupSource, ""},
|
||||||
{lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"},
|
{lookupTimeout, off}, {lookupTimeout, "0s"}, {lookupTimeout, "1"},
|
||||||
{addLookupHeaders, "yes"},
|
{addLookupHeaders, "yes"},
|
||||||
{allowedCountries, "nk"},
|
{deniedCountries, "nk"},
|
||||||
{allowedCountries, "kp,,ir"},
|
{deniedCountries, "kp,,ir"},
|
||||||
{allowedCountries, "prk"},
|
{deniedCountries, "prk"},
|
||||||
{allowedCountries, "408"},
|
{deniedCountries, "408"},
|
||||||
{allowedCountries, "k"},
|
{deniedCountries, "k"},
|
||||||
{allowedCountries, "eu"},
|
{deniedCountries, "eu"},
|
||||||
{allowedCountries, "un"},
|
{deniedCountries, "un"},
|
||||||
{allowedCountries, "su"},
|
{deniedCountries, "su"},
|
||||||
{allowedCountries, "ac"},
|
{allowedCountries, "ac"},
|
||||||
{allowedCountries, "uk"},
|
{allowedCountries, "uk"},
|
||||||
{allowedCountries, "zz"},
|
{allowedCountries, "zz"},
|
||||||
{allowedCountries, "de,germany"},
|
{allowedCountries, "de,germany"},
|
||||||
{asnBytesPercent, "AS14061:50,,AS16276:50"}, {asnBytesPercent, "ASX:50"},
|
{asnLimitPercent, "AS14061:50,,AS16276:50"}, {asnLimitPercent, "ASX:50"},
|
||||||
{asnBytesPercent, "AS14061:"}, {asnBytesPercent, "AS14061 :50"},
|
{asnLimitPercent, "AS14061:"}, {asnLimitPercent, "AS14061 :50"},
|
||||||
{asnBytesPercent, "AS14061:1.5"}, {asnBytesPercent, "AS-1:50"},
|
{asnLimitPercent, "AS14061:1.5"}, {asnLimitPercent, "AS-1:50"},
|
||||||
{countryBytesPercent, "cn"}, {countryBytesPercent, "cn:"},
|
{countryLimitPercent, "cn"}, {countryLimitPercent, "cn:"},
|
||||||
{countryBytesPercent, "cn:25:50"}, {countryBytesPercent, "china:25"},
|
{countryLimitPercent, "cn:25:50"}, {countryLimitPercent, "china:25"},
|
||||||
{tagDeny, "a,,b"}, {tagDeny, "country=nk"}, {tagDeny, "asn=abc"},
|
|
||||||
{tagDeny, "asn="}, {tagDeny, "torexit,TOREXIT"}, {tagDeny, "torexti"},
|
|
||||||
{tagDeny, zenTag}, {tagDeny, "abuseipdb"}, {tagDeny, "crowdsec"},
|
|
||||||
{tagLimitPercent, "torexti:50"}, {tagLimitPercent, "dnsbl=zen.example:50"},
|
|
||||||
{tagLimitPercent, "abuseipdb:50"}, {tagLimitPercent, "crowdsec:50"},
|
|
||||||
{tagLimitPercent, "vpn :50"}, {tagLimitPercent, "vpn"}, {tagLimitPercent, ":50"},
|
|
||||||
{tagLimitPercent, "vpn:"}, {tagLimitPercent, "vpn:0"}, {tagLimitPercent, "vpn:100"},
|
|
||||||
{tagLimitPercent, "vpn:1.5"}, {tagLimitPercent, "vpn:50,VPN:25"},
|
|
||||||
{tagLimitPercent, "exploitsignature:50"}, {tagLimitPercent, "asn=abc:50"},
|
|
||||||
{tagLists, torExitTag}, {tagLists, "=https://example.com/a.txt"},
|
|
||||||
{tagLists, "a,b=https://example.com/a.txt"},
|
|
||||||
{tagLists, "a=ftp://example.com/a.txt"},
|
|
||||||
{tagLists, "a=https://example.com/a.txt,A=https://example.com/b.txt"},
|
|
||||||
{tagLists, "a=https://example.com/a.txt,b=https://example.com/a.txt"},
|
|
||||||
{asnBytesPercent, "AS14061:101"}, {countryBytesPercent, "su:50"},
|
{asnBytesPercent, "AS14061:101"}, {countryBytesPercent, "su:50"},
|
||||||
{unknownLimitPercent, ""}, {unknownLimitPercent, "-1"},
|
{unknownLimitPercent, ""}, {unknownLimitPercent, "-1"},
|
||||||
{unknownLimitPercent, "50%"},
|
{unknownLimitPercent, "50%"},
|
||||||
@@ -2268,25 +2027,6 @@ func TestInvalidValueStopsTheStart(t *testing.T) {
|
|||||||
{logLevel, "INFO"}, {logLevel, "warning"}, {logLevel, "trace"}, {logLevel, ""},
|
{logLevel, "INFO"}, {logLevel, "warning"}, {logLevel, "trace"}, {logLevel, ""},
|
||||||
{rulesEnabled, "yes"}, {rulesEnabled, "True"},
|
{rulesEnabled, "yes"}, {rulesEnabled, "True"},
|
||||||
})
|
})
|
||||||
|
|
||||||
wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff checks that with rule
|
|
||||||
// files off, and either setting turning off the Core Rule Set, the tag
|
|
||||||
// exploitsignature in SWW_TAG_DENY stops the start.
|
|
||||||
func wantExploitTagStopsTheStartWithRulesAndCoreRuleSetOff(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for _, name := range []string{wafMode, wafAnomalyThreshold} {
|
|
||||||
_, err := config.FromEnvironment(environment{
|
|
||||||
tagDeny: exploitTag, rulesEnabled: disabled, name: off,
|
|
||||||
}.lookupEnv)
|
|
||||||
if err == nil || !strings.HasPrefix(err.Error(), tagDeny+": ") {
|
|
||||||
t.Errorf("%s=off with rule files off gave the error %v, want one naming %s",
|
|
||||||
name, err, tagDeny)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
|
func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
|
||||||
@@ -2299,7 +2039,7 @@ func TestInvalidBanOrStateValueStopsTheStart(t *testing.T) {
|
|||||||
{maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off},
|
{maxBanDuration, off}, {maxBanDuration, "1w"}, {attackBanDuration, off},
|
||||||
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
|
{maxBans, off}, {maxBans, "0"}, {maxBans, "5K"},
|
||||||
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
|
{banScopeV4Prefix, "33"}, {banScopeV4Prefix, "-1"}, {banScopeV4Prefix, "/24"},
|
||||||
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/sww"},
|
{stateDir, ""}, {stateDir, "state"}, {stateDir, "./var/lib/smallwebwaf"},
|
||||||
{stateWriteDelay, off}, {stateWriteDelay, "0s"},
|
{stateWriteDelay, off}, {stateWriteDelay, "0s"},
|
||||||
{stateCounterInterval, off}, {stateCounterInterval, "15"},
|
{stateCounterInterval, off}, {stateCounterInterval, "15"},
|
||||||
})
|
})
|
||||||
@@ -2541,11 +2281,11 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
lookupSource: defaultLookupSource,
|
lookupSource: defaultLookupSource,
|
||||||
lookupDBPath: "",
|
lookupDBPath: "",
|
||||||
lookupTimeout: "800ms",
|
lookupTimeout: "800ms",
|
||||||
addLookupHeaders: disabled,
|
addLookupHeaders: "false",
|
||||||
|
deniedCountries: "",
|
||||||
allowedCountries: "",
|
allowedCountries: "",
|
||||||
tagLists: "",
|
asnLimitPercent: "",
|
||||||
tagDeny: "",
|
countryLimitPercent: "",
|
||||||
tagLimitPercent: "",
|
|
||||||
asnBytesPercent: "",
|
asnBytesPercent: "",
|
||||||
countryBytesPercent: "",
|
countryBytesPercent: "",
|
||||||
unknownLimitPercent: "100",
|
unknownLimitPercent: "100",
|
||||||
@@ -2561,7 +2301,6 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
reputationAction: "limit:25",
|
reputationAction: "limit:25",
|
||||||
reputationCacheTTL: defaultReputationCacheTTL,
|
reputationCacheTTL: defaultReputationCacheTTL,
|
||||||
reputationTimeout: "2s",
|
reputationTimeout: "2s",
|
||||||
routeWatchURL: "",
|
|
||||||
crowdSecURL: "",
|
crowdSecURL: "",
|
||||||
crowdSecKey: "",
|
crowdSecKey: "",
|
||||||
banResponse: "403",
|
banResponse: "403",
|
||||||
@@ -2571,7 +2310,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
attackBanDuration: "7d",
|
attackBanDuration: "7d",
|
||||||
maxBans: "5000",
|
maxBans: "5000",
|
||||||
banScopeV4Prefix: "32",
|
banScopeV4Prefix: "32",
|
||||||
stateDir: "/var/lib/sww",
|
stateDir: "/var/lib/smallwebwaf",
|
||||||
stateWriteDelay: "10s",
|
stateWriteDelay: "10s",
|
||||||
stateCounterInterval: "15m",
|
stateCounterInterval: "15m",
|
||||||
adminToken: "",
|
adminToken: "",
|
||||||
@@ -2580,7 +2319,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
instanceName: hostname,
|
instanceName: hostname,
|
||||||
logRequestHeaders: defaultLogRequestHeaders,
|
logRequestHeaders: defaultLogRequestHeaders,
|
||||||
logLevel: "info",
|
logLevel: "info",
|
||||||
rulesDir: "/etc/sww/rules.d",
|
rulesDir: "/etc/smallwebwaf/rules.d",
|
||||||
rulesEnabled: "true",
|
rulesEnabled: "true",
|
||||||
wafMode: config.WAFModeBlock,
|
wafMode: config.WAFModeBlock,
|
||||||
wafParanoiaLevel: "1",
|
wafParanoiaLevel: "1",
|
||||||
@@ -2588,7 +2327,6 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
wafDisabledRules: defaultWAFDisabledRules,
|
wafDisabledRules: defaultWAFDisabledRules,
|
||||||
wafExemptPaths: "",
|
wafExemptPaths: "",
|
||||||
wafBodyLimit: off,
|
wafBodyLimit: off,
|
||||||
exclusionsFile: "",
|
|
||||||
trapPaths: "",
|
trapPaths: "",
|
||||||
errorBurstThreshold: "30",
|
errorBurstThreshold: "30",
|
||||||
logRemoteURL: "",
|
logRemoteURL: "",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RProxy is the settings of `sww rproxy`. A size or time limit of zero
|
// RProxy is the settings of `smallwebwaf rproxy`. A size or time limit of zero
|
||||||
// is off.
|
// is off.
|
||||||
type RProxy struct {
|
type RProxy struct {
|
||||||
// ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR).
|
// ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR).
|
||||||
@@ -22,12 +22,12 @@ type RProxy struct {
|
|||||||
// TrustedProxies are the netblocks whose X-Forwarded-For is believed
|
// TrustedProxies are the netblocks whose X-Forwarded-For is believed
|
||||||
// (SWRP_TRUSTED_PROXIES).
|
// (SWRP_TRUSTED_PROXIES).
|
||||||
TrustedProxies []netip.Prefix
|
TrustedProxies []netip.Prefix
|
||||||
// WAFBackend is discriminator's URL (SWRP_WAF_BACKEND).
|
// WAFBackend is servewaf's URL (SWRP_WAF_BACKEND).
|
||||||
WAFBackend *url.URL
|
WAFBackend *url.URL
|
||||||
// Timeout is how long rproxy waits for discriminator's answer to a question,
|
// Timeout is how long rproxy waits for servewaf's answer to a question,
|
||||||
// and to a report (SWRP_TIMEOUT_MS). It cannot be off.
|
// and to a report (SWRP_TIMEOUT_MS). It cannot be off.
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// DefaultDisposition is what is done with a request when discriminator cannot be
|
// DefaultDisposition is what is done with a request when servewaf cannot be
|
||||||
// connected to, does not answer at all, or does not answer within Timeout:
|
// connected to, does not answer at all, or does not answer within Timeout:
|
||||||
// allow or deny (SWRP_DEFAULT_DISPOSITION).
|
// allow or deny (SWRP_DEFAULT_DISPOSITION).
|
||||||
DefaultDisposition string
|
DefaultDisposition string
|
||||||
@@ -50,7 +50,7 @@ type RProxy struct {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
errNeedsWAFBackend = errors.New(
|
errNeedsWAFBackend = errors.New(
|
||||||
"is required: it is discriminator's URL, such as http://discriminator:8080")
|
"is required: it is servewaf's URL, such as http://servewaf:8080")
|
||||||
errNotDisposition = errors.New("is not allow or deny")
|
errNotDisposition = errors.New("is not allow or deny")
|
||||||
errNotMilliseconds = errors.New(
|
errNotMilliseconds = errors.New(
|
||||||
"is not a whole number of milliseconds above zero, such as 2000")
|
"is not a whole number of milliseconds above zero, such as 2000")
|
||||||
@@ -124,7 +124,7 @@ func (c *RProxy) LogValue() slog.Value {
|
|||||||
return slog.GroupValue(c.settings...)
|
return slog.GroupValue(c.settings...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// wafBackend reads the setting that is discriminator's URL, which must be set.
|
// wafBackend reads the setting that is servewaf's URL, which must be set.
|
||||||
func (e *environment) wafBackend(name string) *url.URL {
|
func (e *environment) wafBackend(name string) *url.URL {
|
||||||
value := e.value(name, "")
|
value := e.value(name, "")
|
||||||
if value == "" {
|
if value == "" {
|
||||||
@@ -139,7 +139,7 @@ func (e *environment) wafBackend(name string) *url.URL {
|
|||||||
return backend
|
return backend
|
||||||
}
|
}
|
||||||
|
|
||||||
// disposition reads the setting that is what is done with a request when discriminator
|
// disposition reads the setting that is what is done with a request when servewaf
|
||||||
// cannot be connected to, does not answer at all, or does not answer in time:
|
// cannot be connected to, does not answer at all, or does not answer in time:
|
||||||
// allow or deny.
|
// allow or deny.
|
||||||
func (e *environment) disposition(name, defaultValue string) string {
|
func (e *environment) disposition(name, defaultValue string) string {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const (
|
|||||||
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE"
|
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE"
|
||||||
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
|
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
|
||||||
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS"
|
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS"
|
||||||
backendURL = "http://discriminator:8080"
|
backendURL = "http://servewaf:8080"
|
||||||
rproxyDefaultAddr = ":8080"
|
rproxyDefaultAddr = ":8080"
|
||||||
rproxyDefaultApp = "http://127.0.0.1:8081"
|
rproxyDefaultApp = "http://127.0.0.1:8081"
|
||||||
notASize = "a lot"
|
notASize = "a lot"
|
||||||
@@ -114,8 +114,8 @@ func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
|
|||||||
name, value string
|
name, value string
|
||||||
}{
|
}{
|
||||||
{wafBackend, ""},
|
{wafBackend, ""},
|
||||||
{wafBackend, "discriminator:8080"},
|
{wafBackend, "servewaf:8080"},
|
||||||
{wafBackend, "http://discriminator:8080/path"},
|
{wafBackend, "http://servewaf:8080/path"},
|
||||||
{rproxyTimeout, "0"},
|
{rproxyTimeout, "0"},
|
||||||
{rproxyTimeout, "-1"},
|
{rproxyTimeout, "-1"},
|
||||||
{rproxyTimeout, off},
|
{rproxyTimeout, off},
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
// The heuristic abuseipdb. It reads SWW_ABUSEIPDB_KEY,
|
|
||||||
// SWW_ABUSEIPDB_MIN_SCORE and SWW_REPUTATION_ACTION.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// abuseIPDB adds the tag abuseipdb and notes AbuseIPDB, as noteHit does, with
|
|
||||||
// the score, when its score of the client is a hit, and refuses the request
|
|
||||||
// when SWW_REPUTATION_ACTION is deny, as dnsbl does for a zone. While
|
|
||||||
// SWW_ABUSEIPDB_KEY is unset it does nothing. A client without a score is
|
|
||||||
// checked in the background, by the request's address, if its history counts
|
|
||||||
// an offence, and the request does not wait for the answer. The score is then
|
|
||||||
// used for each address of the client. Its figures are the score and
|
|
||||||
// SWW_ABUSEIPDB_MIN_SCORE.
|
|
||||||
type abuseIPDB struct{}
|
|
||||||
|
|
||||||
func (abuseIPDB) name() string { return "abuseipdb" }
|
|
||||||
|
|
||||||
func (abuseIPDB) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
|
||||||
if rq.h.config.AbuseIPDBKey == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
client := rq.h.clientGroup(rq.client)
|
|
||||||
held, _ := rq.h.limiter.Client(client)
|
|
||||||
offender := held.History.Offences != ratelimit.Offences{}
|
|
||||||
|
|
||||||
score, hit := rq.h.abuseIPDB.Hit(ctx, client, rq.client, offender)
|
|
||||||
if !hit {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.abuseIPDBHit = true
|
|
||||||
rq.addTag(config.AbuseIPDBTag)
|
|
||||||
rq.noteHit(bans.ReputationHit{Source: reputation.AbuseIPDBSource, Score: &score},
|
|
||||||
"scored by AbuseIPDB at or over SWW_ABUSEIPDB_MIN_SCORE")
|
|
||||||
|
|
||||||
if rq.h.config.ReputationAction != deny {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &verdict{
|
|
||||||
action: requestlog.ActionClientDenied,
|
|
||||||
figures: map[string]any{
|
|
||||||
figureScore: score, "min_score": rq.h.config.AbuseIPDBMinScore,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// whole is the percentage of each limit a client gets when no biased
|
|
||||||
// threshold lowers its limits.
|
|
||||||
const whole = 100
|
|
||||||
|
|
||||||
// percentage is a client's limit percentage for the rate limits or for
|
|
||||||
// the byte limits, as the biased thresholds give it, and the setting that
|
|
||||||
// gave it: "" with whole when none lowers that kind of limit.
|
|
||||||
type percentage struct {
|
|
||||||
percent int64
|
|
||||||
setting string
|
|
||||||
}
|
|
||||||
|
|
||||||
// biasedThresholdsSet reports whether a biased threshold can lower a
|
|
||||||
// client's limits by its lookup: SWW_ASN_BYTES_PERCENT or
|
|
||||||
// SWW_COUNTRY_BYTES_PERCENT is not empty, SWW_UNKNOWN_LIMIT_PERCENT is
|
|
||||||
// below 100, or SWW_ASN_LIMIT_PERCENT_URL is set. The client's lookup is
|
|
||||||
// then needed before its request goes on.
|
|
||||||
func biasedThresholdsSet(cfg *config.Config) bool {
|
|
||||||
return len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
|
|
||||||
cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// limitPercentages returns the client's limit percentages, for the rate
|
|
||||||
// limits and for the byte limits, by its tags, its AS number and country
|
|
||||||
// as looked up, each "" when unknown, and the blocklists, DNSBL zones and
|
|
||||||
// AbuseIPDB that list it. Each is the lowest of those the settings give
|
|
||||||
// it, the first of them in the order below when several are lowest: the
|
|
||||||
// percentage SWW_TAG_LIMIT_PERCENT gives its asn= tag, the one the file
|
|
||||||
// SWW_ASN_LIMIT_PERCENT_URL names gives its AS number, the one
|
|
||||||
// SWW_TAG_LIMIT_PERCENT gives its country= tag, the lowest it gives its
|
|
||||||
// other tags, for a client without a country, unless its request skipped
|
|
||||||
// the wait for GeoJS, SWW_UNKNOWN_LIMIT_PERCENT, for a client a blocklist
|
|
||||||
// lists, the percentage of SWW_BLOCKLIST_ACTION while it is limit, and
|
|
||||||
// for a client a DNSBL zone's verdict lists, or whose AbuseIPDB score is
|
|
||||||
// a hit, the percentage of SWW_REPUTATION_ACTION while it is limit. For
|
|
||||||
// the byte limits, SWW_ASN_BYTES_PERCENT and SWW_COUNTRY_BYTES_PERCENT
|
|
||||||
// take the place of the first three for an AS number or a country they
|
|
||||||
// list.
|
|
||||||
func (rq *request) limitPercentages() (percentage, percentage) {
|
|
||||||
cfg := rq.h.config
|
|
||||||
asn, country := rq.line.ASN, rq.line.Country
|
|
||||||
|
|
||||||
unknown := percentage{percent: whole}
|
|
||||||
if country == "" && !rq.skippedLookupWait {
|
|
||||||
unknown = percentage{cfg.UnknownLimitPercent, "SWW_UNKNOWN_LIMIT_PERCENT"}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetched := percentage{percent: whole}
|
|
||||||
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
|
|
||||||
fetched = percentage{percent, "SWW_ASN_LIMIT_PERCENT_URL"}
|
|
||||||
}
|
|
||||||
|
|
||||||
blocklisted := percentage{percent: whole}
|
|
||||||
if rq.blocklisted && cfg.BlocklistAction == "limit" {
|
|
||||||
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWW_BLOCKLIST_ACTION"}
|
|
||||||
}
|
|
||||||
|
|
||||||
reputationListed := percentage{percent: whole}
|
|
||||||
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
|
|
||||||
reputationListed = percentage{cfg.ReputationLimitPercent, "SWW_REPUTATION_ACTION"}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagSetting = "SWW_TAG_LIMIT_PERCENT"
|
|
||||||
|
|
||||||
asnRequests := lowest(given(cfg.TagLimitPercent, asnTag(asn), tagSetting), fetched)
|
|
||||||
countryRequests := given(cfg.TagLimitPercent, countryTag(country), tagSetting)
|
|
||||||
|
|
||||||
otherTags := percentage{percent: whole}
|
|
||||||
|
|
||||||
for _, tag := range rq.line.Tags {
|
|
||||||
if tag != asnTag(asn) && tag != countryTag(country) {
|
|
||||||
otherTags = lowest(otherTags, given(cfg.TagLimitPercent, tag, tagSetting))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
asnBytes, countryBytes := asnRequests, countryRequests
|
|
||||||
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
|
||||||
asnBytes = given(cfg.ASNBytesPercent, asn, "SWW_ASN_BYTES_PERCENT")
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, listed := cfg.CountryBytesPercent[country]; listed {
|
|
||||||
countryBytes = given(cfg.CountryBytesPercent, country, "SWW_COUNTRY_BYTES_PERCENT")
|
|
||||||
}
|
|
||||||
|
|
||||||
return lowest(asnRequests, countryRequests, otherTags, unknown, blocklisted,
|
|
||||||
reputationListed),
|
|
||||||
lowest(asnBytes, countryBytes, otherTags, unknown, blocklisted, reputationListed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// given returns the percentage percents, the setting named setting, gives
|
|
||||||
// code, an AS number, a country or a tag, or whole when it does not list code.
|
|
||||||
func given(percents map[string]int64, code, setting string) percentage {
|
|
||||||
percent, listed := percents[code]
|
|
||||||
if !listed {
|
|
||||||
return percentage{percent: whole}
|
|
||||||
}
|
|
||||||
|
|
||||||
return percentage{percent, setting}
|
|
||||||
}
|
|
||||||
|
|
||||||
// lowest returns the lowest of percentages below whole, the first of them
|
|
||||||
// when several are lowest, or whole when none is below it.
|
|
||||||
func lowest(percentages ...percentage) percentage {
|
|
||||||
low := percentage{percent: whole}
|
|
||||||
|
|
||||||
for _, p := range percentages {
|
|
||||||
if p.percent < low.percent {
|
|
||||||
low = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return low
|
|
||||||
}
|
|
||||||
|
|
||||||
// logged returns p as the log line and the notes of a ban give it: its
|
|
||||||
// percent and setting, or nil and "" for whole, which they leave out.
|
|
||||||
func (p percentage) logged() (*int64, string) {
|
|
||||||
if p.percent == whole {
|
|
||||||
return nil, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return &p.percent, p.setting
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
// The heuristic blocklists. It reads SWW_BLOCKLIST_ACTION, and the blocklists
|
|
||||||
// through the reputation lists.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// blocklists notes the blocklists that list the client, as noteListed does,
|
|
||||||
// and refuses the request when SWW_BLOCKLIST_ACTION is deny. Being limit, it
|
|
||||||
// lowers the client's limits instead (see limitPercentages), and being log, it
|
|
||||||
// does nothing more. Its figure is the blocklists that list the client.
|
|
||||||
type blocklists struct{}
|
|
||||||
|
|
||||||
func (blocklists) name() string { return "blocklists" }
|
|
||||||
|
|
||||||
func (blocklists) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
|
||||||
listedBy := rq.h.lists.ListedBy(rq.client)
|
|
||||||
rq.blocklisted = len(listedBy) > 0
|
|
||||||
rq.noteListed(listedBy, "listed by a blocklist")
|
|
||||||
|
|
||||||
if !rq.refusedByBlocklists(listedBy) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &verdict{
|
|
||||||
action: requestlog.ActionClientDenied,
|
|
||||||
figures: map[string]any{"lists": listedBy},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// refusedByBlocklists reports whether the heuristic blocklists refuses a
|
|
||||||
// client, given listedBy, the blocklists that list it: it does when one lists
|
|
||||||
// it and SWW_BLOCKLIST_ACTION is deny.
|
|
||||||
func (rq *request) refusedByBlocklists(listedBy []string) bool {
|
|
||||||
return len(listedBy) > 0 && rq.h.config.BlocklistAction == deny
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
// The heuristic byte_limits. It reads SWW_BYTES_COUNT, and the byte limits
|
|
||||||
// and biased thresholds through the limiter and limitPercentages.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// byteLimits counts the request's bytes, as countedBytes gives them, for the
|
|
||||||
// byte limits, once its report has come, and notes the client's byte totals for
|
|
||||||
// the log line; its requests stay there as the rate limits counted them. Only
|
|
||||||
// an allowed request has them counted, and only one the rate limits counted; in
|
|
||||||
// observe mode, not one that enforce mode would have refused. Bytes that take
|
|
||||||
// the client over a byte limit, as its limit percentage for the byte limits
|
|
||||||
// lowers it, break it, which bans the client; the response was passed on whole.
|
|
||||||
// Its figures are the window, the count and the limit.
|
|
||||||
type byteLimits struct{}
|
|
||||||
|
|
||||||
func (byteLimits) name() string { return "byte_limits" }
|
|
||||||
|
|
||||||
func (byteLimits) check(_ context.Context, rq *request, now time.Time) *verdict {
|
|
||||||
if !rq.counted || rq.line.WouldAction != "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
|
|
||||||
rq.countedBytes(), rq.bytesPercent.percent)
|
|
||||||
rq.line.Counts.MinuteBytes = counts.MinuteBytes
|
|
||||||
rq.line.Counts.HourBytes = counts.HourBytes
|
|
||||||
rq.line.Counts.DayBytes = counts.DayBytes
|
|
||||||
|
|
||||||
if !over {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.banForLimit(now, hit, rq.out.status)
|
|
||||||
|
|
||||||
return &verdict{figures: limitFigures(hit)}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
// The heuristic countries. It reads SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"slices"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// countries refuses a request when SWW_EXCLUSIVELY_ALLOWED_COUNTRIES is set and
|
|
||||||
// does not hold the client's country as it was looked up. A client without a
|
|
||||||
// country, or whose country cannot be found, is refused. Its figures are the
|
|
||||||
// country and the setting that refused it.
|
|
||||||
type countries struct{}
|
|
||||||
|
|
||||||
func (countries) name() string { return "countries" }
|
|
||||||
|
|
||||||
func (countries) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
|
||||||
allowed := rq.h.config.ExclusivelyAllowedCountries
|
|
||||||
country := rq.line.Country
|
|
||||||
|
|
||||||
if len(allowed) == 0 || slices.Contains(allowed, country) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &verdict{
|
|
||||||
action: requestlog.ActionCountryDenied,
|
|
||||||
figures: map[string]any{
|
|
||||||
"country": country, "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
// The heuristic crowdsec. It reads SWW_CROWDSEC_LAPI_URL, and the decisions
|
|
||||||
// through the reputation lists.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// crowdSec refuses a request when a decision of the CrowdSec decision list on
|
|
||||||
// the client is in force at now. It then adds the tag crowdsec, notes the
|
|
||||||
// list, as noteHit does, and bans the client until that decision ends. Its
|
|
||||||
// figures are the decision's scenario and when it ends.
|
|
||||||
type crowdSec struct{}
|
|
||||||
|
|
||||||
func (crowdSec) name() string { return "crowdsec" }
|
|
||||||
|
|
||||||
func (crowdSec) check(_ context.Context, rq *request, now time.Time) *verdict {
|
|
||||||
decision, listed := rq.crowdSecDecisionInForce(now)
|
|
||||||
if !listed {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.addTag(config.CrowdSecTag)
|
|
||||||
rq.noteHit(bans.ReputationHit{Source: rq.h.config.CrowdSecDecisionsURL},
|
|
||||||
"listed by the CrowdSec decision list")
|
|
||||||
|
|
||||||
return &verdict{
|
|
||||||
action: rq.banForCrowdSec(now, decision),
|
|
||||||
figures: map[string]any{
|
|
||||||
"scenario": decision.Scenario,
|
|
||||||
"expires": requestlog.FormatTime(decision.Expires),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// crowdSecDecisionInForce returns the decision of the CrowdSec decision list on
|
|
||||||
// the client that is in force at now, and reports whether there is one, which
|
|
||||||
// the heuristic crowdsec refuses the request for.
|
|
||||||
func (rq *request) crowdSecDecisionInForce(
|
|
||||||
now time.Time,
|
|
||||||
) (reputation.Decision, bool) {
|
|
||||||
return rq.h.lists.CrowdSecDecision(rq.client, now)
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
// The heuristic deny_nets. It reads SWW_DENY_NETS.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// denyNets refuses a client in SWW_DENY_NETS. Its figure is the netblock of
|
|
||||||
// the setting the client is in.
|
|
||||||
type denyNets struct{}
|
|
||||||
|
|
||||||
func (denyNets) name() string { return "deny_nets" }
|
|
||||||
|
|
||||||
func (denyNets) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
|
||||||
for _, netblock := range rq.h.config.DenyNets {
|
|
||||||
if netblock.Contains(rq.client) {
|
|
||||||
return &verdict{
|
|
||||||
action: requestlog.ActionClientDenied,
|
|
||||||
figures: map[string]any{figureNetblock: netblock.String()},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
// The heuristic dnsbl. It reads SWW_REPUTATION_ACTION, and the DNSBL zones'
|
|
||||||
// verdicts through the DNSBL checker.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// dnsbl notes the DNSBL zones whose verdict lists the client, as noteListed
|
|
||||||
// does, adds the tag dnsbl=<zone> for each, the zone's key masked, and refuses
|
|
||||||
// the request when SWW_REPUTATION_ACTION is deny. Being
|
|
||||||
// limit, it lowers the client's limits instead (see limitPercentages), and
|
|
||||||
// being log, it does nothing more. A zone without a verdict on the client is
|
|
||||||
// asked about it in the background, and the request does not wait for the
|
|
||||||
// answer. Its figure is the zones that list the client, their keys masked.
|
|
||||||
type dnsbl struct{}
|
|
||||||
|
|
||||||
func (dnsbl) name() string { return "dnsbl" }
|
|
||||||
|
|
||||||
func (dnsbl) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
|
||||||
listedBy := rq.h.dnsbl.ListedBy(ctx, rq.client)
|
|
||||||
rq.dnsblListed = len(listedBy) > 0
|
|
||||||
rq.noteListed(listedBy, "listed by a DNSBL zone")
|
|
||||||
|
|
||||||
for _, zone := range listedBy {
|
|
||||||
rq.addTag(config.DNSBLTagPrefix + strings.ToLower(zone))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !rq.dnsblListed || rq.h.config.ReputationAction != deny {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &verdict{
|
|
||||||
action: requestlog.ActionClientDenied,
|
|
||||||
figures: map[string]any{"zones": listedBy},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
// The heuristic header_limits. It reads no setting: its limits are fixed.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The header limits: the most cookies and header fields a request may have
|
|
||||||
// when the checks reach the Core Rule Set. Real clients never reach them.
|
|
||||||
const (
|
|
||||||
maxCookies = 50
|
|
||||||
maxHeaderFields = 100
|
|
||||||
)
|
|
||||||
|
|
||||||
// headerLimits refuses a request over the header limits, with 431. Its figures
|
|
||||||
// are the request's header fields and cookies, and the limit of each.
|
|
||||||
type headerLimits struct{}
|
|
||||||
|
|
||||||
func (headerLimits) name() string { return "header_limits" }
|
|
||||||
|
|
||||||
func (headerLimits) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
|
||||||
fields, cookies := rq.headerCounts()
|
|
||||||
if !overHeaderLimits(fields, cookies) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &verdict{
|
|
||||||
action: requestlog.ActionRequestHeaderTooLarge,
|
|
||||||
figures: map[string]any{
|
|
||||||
"fields": fields, "fields_limit": maxHeaderFields,
|
|
||||||
"cookies": cookies, "cookies_limit": maxCookies,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// overHeaderLimits reports whether a request with these counts of header fields
|
|
||||||
// and cookies, as headerCounts counts them, is over the header limits: it has
|
|
||||||
// more than maxHeaderFields fields or more than maxCookies cookies.
|
|
||||||
func overHeaderLimits(fields, cookies int) bool {
|
|
||||||
return fields > maxHeaderFields || cookies > maxCookies
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
// semicolons being one unless it is empty or only spaces and tabs. The Core
|
|
||||||
// Rule Set runs each of its rules on each cookie, so that thousands of small
|
|
||||||
// cookies keep it busy for over a second. It trims only spaces and control
|
|
||||||
// characters, of which a header value can hold only tabs, so a part of Unicode
|
|
||||||
// spaces is a cookie to it.
|
|
||||||
func (rq *request) headerCounts() (int, int) {
|
|
||||||
fields, cookies := 0, 0
|
|
||||||
|
|
||||||
for _, values := range rq.in.Header {
|
|
||||||
fields += len(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, value := range rq.in.Header.Values("Cookie") {
|
|
||||||
for part := range strings.SplitSeq(value, ";") {
|
|
||||||
if strings.Trim(part, " \t") != "" {
|
|
||||||
cookies++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fields, cookies
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// To add a heuristic, write one file with a type that implements heuristic,
|
|
||||||
// and add one line for it to one of the lists below. Its settings go in
|
|
||||||
// config.Config, and the comment at the top of its file names them.
|
|
||||||
|
|
||||||
// heuristic is one of the checks that decide what discriminator does with a
|
|
||||||
// request.
|
|
||||||
type heuristic interface {
|
|
||||||
// name is the heuristic's name, which the log line gives.
|
|
||||||
name() string
|
|
||||||
// check looks at the request at now, and returns nil to let it through.
|
|
||||||
// ctx is the request's own context.
|
|
||||||
check(ctx context.Context, rq *request, now time.Time) *verdict
|
|
||||||
}
|
|
||||||
|
|
||||||
// verdict is a heuristic refusing a request, or banning its client: the action
|
|
||||||
// the log line names, one of requestlog's Action constants, and the figures the
|
|
||||||
// heuristic acted on, by name, such as the count and the limit.
|
|
||||||
type verdict struct {
|
|
||||||
action string
|
|
||||||
figures map[string]any
|
|
||||||
}
|
|
||||||
|
|
||||||
// The names of the figures that more than one heuristic gives.
|
|
||||||
const (
|
|
||||||
figureWindow = "window"
|
|
||||||
figureCount = "count"
|
|
||||||
figureLimit = "limit"
|
|
||||||
figureScore = "score"
|
|
||||||
figureNetblock = "netblock"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The lists of heuristics, each in the order its heuristics run. A client in
|
|
||||||
// SWW_ALLOW_NETS skips them all.
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
func beforeLookup() []heuristic {
|
|
||||||
return []heuristic{
|
|
||||||
tagLists{},
|
|
||||||
routeWatchTags{},
|
|
||||||
denyNets{},
|
|
||||||
activeBan{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// afterLookup are the heuristics that run once the lookup has been made.
|
|
||||||
// tag_deny runs twice: before the rate limits, on the tags known by then, and
|
|
||||||
// last, on the tag rule_files and core_rule_set can add.
|
|
||||||
func afterLookup() []heuristic {
|
|
||||||
return []heuristic{
|
|
||||||
countries{},
|
|
||||||
blocklists{},
|
|
||||||
crowdSec{},
|
|
||||||
dnsbl{},
|
|
||||||
abuseIPDB{},
|
|
||||||
tagDeny{},
|
|
||||||
rateLimits{},
|
|
||||||
trapPaths{},
|
|
||||||
ruleFiles{},
|
|
||||||
headerLimits{},
|
|
||||||
coreRuleSet{},
|
|
||||||
tagDeny{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// afterAnswer are the heuristics that run on a request once discriminator has
|
|
||||||
// answered it, before its decision line is written. They ban the client, and do
|
|
||||||
// not change what the request got: their verdict's action is not used.
|
|
||||||
func afterAnswer() []heuristic {
|
|
||||||
return []heuristic{
|
|
||||||
refusalLimit{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// onReport are the heuristics that run on an allowed request when rproxy's
|
|
||||||
// report of it comes, before its report line is written. Like afterAnswer, they
|
|
||||||
// only ban.
|
|
||||||
func onReport() []heuristic {
|
|
||||||
return []heuristic{
|
|
||||||
byteLimits{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// runAfterAnswer runs the heuristics of afterAnswer on the request, at the time
|
|
||||||
// it is called.
|
|
||||||
func (rq *request) runAfterAnswer(ctx context.Context) {
|
|
||||||
rq.runBanningHeuristics(ctx, afterAnswer())
|
|
||||||
}
|
|
||||||
|
|
||||||
// runHeuristics runs list, beforeLookup or afterLookup, on the request at now,
|
|
||||||
// and returns the first verdict, or nil when none gives one. The log line's
|
|
||||||
// heuristic and heuristic_figures name the heuristic that gave it, and give its
|
|
||||||
// figures. ctx is the request's own context.
|
|
||||||
func (rq *request) runHeuristics(
|
|
||||||
ctx context.Context, list []heuristic, now time.Time,
|
|
||||||
) *verdict {
|
|
||||||
name, v := rq.firstVerdict(ctx, list, now)
|
|
||||||
if v != nil {
|
|
||||||
rq.line.Heuristic = name
|
|
||||||
rq.line.HeuristicFigures = v.figures
|
|
||||||
}
|
|
||||||
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// runBanningHeuristics runs list, afterAnswer or onReport, on the request at
|
|
||||||
// the time it is called. The log line's after_answer_heuristic and
|
|
||||||
// after_answer_heuristic_figures name the heuristic that gave a verdict, and
|
|
||||||
// give its figures, so that heuristic and heuristic_figures still name the one
|
|
||||||
// that refused the request. ctx is the request's own context.
|
|
||||||
func (rq *request) runBanningHeuristics(ctx context.Context, list []heuristic) {
|
|
||||||
name, v := rq.firstVerdict(ctx, list, rq.h.now())
|
|
||||||
if v != nil {
|
|
||||||
rq.line.AfterAnswerHeuristic = name
|
|
||||||
rq.line.AfterAnswerHeuristicFigures = v.figures
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// firstVerdict runs list on the request at now, in order, until a heuristic
|
|
||||||
// gives a verdict, and returns that heuristic's name and its verdict, or "" and
|
|
||||||
// nil when none gives one. ctx is the request's own context.
|
|
||||||
func (rq *request) firstVerdict(
|
|
||||||
ctx context.Context, list []heuristic, now time.Time,
|
|
||||||
) (string, *verdict) {
|
|
||||||
for _, each := range list {
|
|
||||||
v := each.check(ctx, rq, now)
|
|
||||||
if v != nil {
|
|
||||||
return each.name(), v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
// The heuristic rate_limits. It reads SWW_RATE_LIMIT_EXEMPT_NETS,
|
|
||||||
// SWW_RATE_LIMIT_EXEMPT_PATHS and SWW_BAN_RESPONSE, and the rate limits and
|
|
||||||
// biased thresholds through the limiter and limitPercentages.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// rateLimits counts the request for the rate limits, unless
|
|
||||||
// countedByRateLimits exempts it, by the client's limit percentages, which it
|
|
||||||
// notes for the log line with the client's counts. A request that takes the
|
|
||||||
// client over a rate limit, as its limit percentage lowers it, breaks it: it is
|
|
||||||
// refused, and bans the client. A request exempt from the rate limits is exempt
|
|
||||||
// from the byte limits too. Its figures are the window, the count and the
|
|
||||||
// limit.
|
|
||||||
type rateLimits struct{}
|
|
||||||
|
|
||||||
func (rateLimits) name() string { return "rate_limits" }
|
|
||||||
|
|
||||||
func (rateLimits) check(_ context.Context, rq *request, now time.Time) *verdict {
|
|
||||||
rq.counted = rq.countedByRateLimits()
|
|
||||||
if !rq.counted {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.limitPercent, rq.bytesPercent = rq.limitPercentages()
|
|
||||||
rq.line.LimitPercent, rq.line.LimitPercentSetting = rq.limitPercent.logged()
|
|
||||||
rq.line.BytesPercent, rq.line.BytesPercentSetting = rq.bytesPercent.logged()
|
|
||||||
|
|
||||||
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
|
|
||||||
rq.limitPercent.percent)
|
|
||||||
rq.line.Counts = counts
|
|
||||||
|
|
||||||
if !over {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.banForLimit(now, hit, rq.h.config.BanResponse)
|
|
||||||
|
|
||||||
return &verdict{
|
|
||||||
action: requestlog.ActionClientIsRateLimited, figures: limitFigures(hit),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// overAWholeRateLimit reports whether the heuristic rate_limits will refuse the
|
|
||||||
// request at now, whatever its client's limit percentages: the rate limits
|
|
||||||
// count it, and one more request from its client takes it over a whole rate
|
|
||||||
// limit. It counts nothing.
|
|
||||||
func (rq *request) overAWholeRateLimit(now time.Time) bool {
|
|
||||||
return rq.countedByRateLimits() &&
|
|
||||||
rq.h.limiter.OverARateLimit(rq.h.clientGroup(rq.client), now)
|
|
||||||
}
|
|
||||||
|
|
||||||
// countedByRateLimits reports whether the rate limits count the request: its
|
|
||||||
// client is not in SWW_RATE_LIMIT_EXEMPT_NETS, and its path is not exempt
|
|
||||||
// under SWW_RATE_LIMIT_EXEMPT_PATHS.
|
|
||||||
func (rq *request) countedByRateLimits() bool {
|
|
||||||
cfg := rq.h.config
|
|
||||||
|
|
||||||
return !isInside(rq.client, cfg.RateLimitExemptNets) &&
|
|
||||||
!pathExempt(rq.in, cfg.RateLimitExemptPaths)
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
// The heuristic refusal_limit. It reads SWW_ERROR_BURST_THRESHOLD and
|
|
||||||
// SWW_BAN_RESPONSE.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// refusalLimit counts the request once it has been answered, if discriminator
|
|
||||||
// refused it after a rule file match, a trap path or a Core Rule Set match, and
|
|
||||||
// in observe mode if enforce mode would have: more than
|
|
||||||
// SWW_ERROR_BURST_THRESHOLD such refusals of the client within a minute break
|
|
||||||
// a limit, which bans the client. A client in SWW_ALLOW_NETS, which the
|
|
||||||
// heuristics skip, is not counted, and nothing is while the threshold is off.
|
|
||||||
// Its figures are the window, the count and the limit.
|
|
||||||
type refusalLimit struct{}
|
|
||||||
|
|
||||||
func (refusalLimit) name() string { return "refusal_limit" }
|
|
||||||
|
|
||||||
func (refusalLimit) check(_ context.Context, rq *request, now time.Time) *verdict {
|
|
||||||
cfg := rq.h.config
|
|
||||||
if cfg.ErrorBurstThreshold == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if !rq.attack && !rq.blockedByBlockRule && !rq.wafBlocked {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
|
|
||||||
cfg.ErrorBurstThreshold)
|
|
||||||
if !over {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the client was sent, or in observe mode would have been.
|
|
||||||
status := rq.out.status
|
|
||||||
|
|
||||||
switch rq.line.WouldAction {
|
|
||||||
case requestlog.ActionBlockedByBlockRule, requestlog.ActionWAFBlocked:
|
|
||||||
status = http.StatusForbidden
|
|
||||||
case requestlog.ActionTemporarilyBanned, requestlog.ActionPermanentlyBanned:
|
|
||||||
status = cfg.BanResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.banForLimit(now, hit, status)
|
|
||||||
|
|
||||||
return &verdict{figures: limitFigures(hit)}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
// What the reputation heuristics share: blocklists, crowdsec, dnsbl and
|
|
||||||
// abuseipdb.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
||||||
)
|
|
||||||
|
|
||||||
// deny is the SWW_BLOCKLIST_ACTION and the SWW_REPUTATION_ACTION that
|
|
||||||
// refuses the requests of a client a source lists.
|
|
||||||
const deny = "deny"
|
|
||||||
|
|
||||||
// noteListed notes each of sources, the URLs of the blocklists or the
|
|
||||||
// DNSBL zones, their keys masked, that list the client, as noteHit does,
|
|
||||||
// with reason.
|
|
||||||
func (rq *request) noteListed(sources []string, reason string) {
|
|
||||||
for _, source := range sources {
|
|
||||||
rq.noteHit(bans.ReputationHit{Source: source}, reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// noteHit adds hit's source, which lists the client, to the log line's
|
|
||||||
// reputation, and hit to the notes of a ban the request makes, counts the
|
|
||||||
// source in the metrics, and raises a reputation_hit alert with reason,
|
|
||||||
// whose detail gives hit's source and score.
|
|
||||||
func (rq *request) noteHit(hit bans.ReputationHit, reason string) {
|
|
||||||
detail := map[string]any{"source": hit.Source}
|
|
||||||
if hit.Score != nil {
|
|
||||||
detail["score"] = *hit.Score
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.line.Reputation = append(rq.line.Reputation, hit.Source)
|
|
||||||
rq.reputation = append(rq.reputation, hit)
|
|
||||||
rq.h.metrics.ReputationHit(hit.Source)
|
|
||||||
rq.h.alerts.Raise(alerts.Alert{
|
|
||||||
Event: alerts.EventReputationHit,
|
|
||||||
Host: rq.site,
|
|
||||||
Client: rq.client,
|
|
||||||
Netblock: rq.h.clientGroup(rq.client),
|
|
||||||
ASN: rq.line.ASN,
|
|
||||||
ASName: rq.line.ASName,
|
|
||||||
Country: rq.line.Country,
|
|
||||||
Reason: reason,
|
|
||||||
Detail: detail,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
// The heuristic routewatch_tags. It reads SWW_ROUTEWATCH_URL.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// routeWatchTags adds to the request the tags asn=<number> and prefix=<netblock>
|
|
||||||
// from what routewatch answered about the client. It refuses nothing, and the
|
|
||||||
// request does not wait for routewatch. A request that gets no tags from it has
|
|
||||||
// the reason in the log line's routewatch_tags_missing. While SWW_ROUTEWATCH_URL
|
|
||||||
// is unset it does nothing. Answers are kept by client and asked for by the
|
|
||||||
// request's address, and a route without an origin AS gives only the prefix= tag.
|
|
||||||
type routeWatchTags struct{}
|
|
||||||
|
|
||||||
func (routeWatchTags) name() string { return "routewatch_tags" }
|
|
||||||
|
|
||||||
func (routeWatchTags) check(ctx context.Context, rq *request, _ time.Time) *verdict {
|
|
||||||
if rq.h.routeWatch == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
route, missing := rq.h.routeWatch.Route(ctx, rq.h.clientGroup(rq.client), rq.client)
|
|
||||||
if missing != "" {
|
|
||||||
rq.line.RouteWatchTagsMissing = string(missing)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.addTag(config.PrefixTagPrefix + route.Netblock.String())
|
|
||||||
|
|
||||||
if route.ASN != 0 {
|
|
||||||
rq.addTag(config.ASNTagPrefix + strconv.FormatUint(uint64(route.ASN), 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
package discriminator_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/netip"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// routeWatchURL is SWW_ROUTEWATCH_URL, and tagsMissing the log line's field
|
|
||||||
// that says why a request got no tags from routewatch.
|
|
||||||
const (
|
|
||||||
routeWatchURL = "SWW_ROUTEWATCH_URL"
|
|
||||||
tagsMissing = "routewatch_tags_missing"
|
|
||||||
fromV6 = "2001:db8:1::5"
|
|
||||||
prefixV6 = "2001:db8::/32"
|
|
||||||
prefixDE = "203.0.113.0/24"
|
|
||||||
prefixKP = "198.51.100.0/24"
|
|
||||||
)
|
|
||||||
|
|
||||||
// standInRoute is what the stand-in for routewatch answers about an address.
|
|
||||||
type standInRoute struct {
|
|
||||||
Netblock string `json:"netblock"`
|
|
||||||
ASN int `json:"asn"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// startRouteWatch starts a stand-in for routewatch's IP info API that knows the
|
|
||||||
// routes given by address, and returns its URL.
|
|
||||||
func startRouteWatch(t *testing.T, routes map[string]standInRoute) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
address := strings.TrimPrefix(r.URL.Path, "/api/v1/ip/")
|
|
||||||
|
|
||||||
result := map[string]any{"ip": address}
|
|
||||||
if route, known := routes[address]; known {
|
|
||||||
result["netblock"], result["asn"] = route.Netblock, route.ASN
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"status": "ok",
|
|
||||||
"data": map[string]any{"results": []any{result}},
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
|
|
||||||
return server.URL
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitForRouteWatch waits until routewatch's answer about from, in the client
|
|
||||||
// group of the given length, is kept, so that the next request gets its tags.
|
|
||||||
func waitForRouteWatch(
|
|
||||||
t *testing.T, server *discriminator.Server, from string, bits int,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
addr := netip.MustParseAddr(from)
|
|
||||||
client := netip.PrefixFrom(addr, bits).Masked()
|
|
||||||
deadline := time.Now().Add(5 * time.Second)
|
|
||||||
|
|
||||||
for {
|
|
||||||
_, missing := server.RouteWatch.Route(context.Background(), client, addr)
|
|
||||||
if missing == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatalf("routewatch's answer about %s not kept: %q", from, missing)
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func wantTagsMissing(t *testing.T, line logLine, want string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
got, _ := line.fields[tagsMissing].(string)
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("log line has %s %q, want %q", tagsMissing, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouteWatchTagsAreOnTheLogLineWithTheLookupsTags(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 64496}})
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{routeWatchURL: url})
|
|
||||||
|
|
||||||
waitForRouteWatch(t, server, fromDE, 32)
|
|
||||||
|
|
||||||
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
||||||
wantTags(t, line, "asn=64496", "country=DE", "prefix="+prefixDE)
|
|
||||||
wantTagsMissing(t, line, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouteWatchTagsAreOnALineDenyNetsRefused(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
url := startRouteWatch(t, map[string]standInRoute{fromKP: {prefixKP, 64511}})
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
|
||||||
lookupSource: off, routeWatchURL: url, denyNets: fromKP,
|
|
||||||
})
|
|
||||||
|
|
||||||
waitForRouteWatch(t, server, fromKP, 32)
|
|
||||||
|
|
||||||
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
|
|
||||||
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+fromKP+`/32"}`)
|
|
||||||
wantTags(t, line, "asn=64511", "prefix="+prefixKP)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouteWithoutAnOriginASGivesOnlyThePrefixTag(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
url := startRouteWatch(t, map[string]standInRoute{fromDE: {prefixDE, 0}})
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
|
||||||
lookupSource: off, routeWatchURL: url,
|
|
||||||
})
|
|
||||||
|
|
||||||
waitForRouteWatch(t, server, fromDE, 32)
|
|
||||||
|
|
||||||
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
|
||||||
"prefix="+prefixDE)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnsetRouteWatchChangesNothing(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{lookupSource: off})
|
|
||||||
|
|
||||||
if server.RouteWatch != nil {
|
|
||||||
t.Error("RouteWatch is set while SWW_ROUTEWATCH_URL is unset")
|
|
||||||
}
|
|
||||||
|
|
||||||
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
||||||
if _, present := line.fields[tagsMissing]; present {
|
|
||||||
t.Errorf("log line has %s", tagsMissing)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, present := line.fields["tags"]; present {
|
|
||||||
t.Errorf("log line has tags %v", line.fields["tags"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnreachableRouteWatchLetsTheRequestThroughAndSaysWhy(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// A stand-in that is closed at once, so that the port refuses connections.
|
|
||||||
dead := httptest.NewServer(http.NotFoundHandler())
|
|
||||||
url := dead.URL
|
|
||||||
|
|
||||||
dead.Close()
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
|
||||||
lookupSource: off, routeWatchURL: url,
|
|
||||||
})
|
|
||||||
|
|
||||||
wantTagsMissing(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
|
||||||
"no_answer_yet")
|
|
||||||
|
|
||||||
deadline := time.Now().Add(5 * time.Second)
|
|
||||||
|
|
||||||
for {
|
|
||||||
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
||||||
|
|
||||||
missing, _ := line.fields[tagsMissing].(string)
|
|
||||||
if missing == "request_failed" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatalf("still %q, want request_failed", missing)
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddressWithoutARouteHasNoTagsAndTheLineSaysSo(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
|
||||||
lookupSource: off, routeWatchURL: startRouteWatch(t, nil),
|
|
||||||
})
|
|
||||||
|
|
||||||
deadline := time.Now().Add(5 * time.Second)
|
|
||||||
|
|
||||||
for {
|
|
||||||
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
||||||
|
|
||||||
missing, _ := line.fields[tagsMissing].(string)
|
|
||||||
if missing == "no_route" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatalf("still %q, want no_route", missing)
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagDenyRefusesOnARouteWatchPrefix(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
url := startRouteWatch(t, map[string]standInRoute{
|
|
||||||
fromDE: {prefixDE, 64496}, fromKP: {prefixKP, 64511},
|
|
||||||
})
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
|
||||||
lookupSource: off, routeWatchURL: url, tagDeny: "prefix=" + prefixKP,
|
|
||||||
})
|
|
||||||
|
|
||||||
waitForRouteWatch(t, server, fromKP, 32)
|
|
||||||
waitForRouteWatch(t, server, fromDE, 32)
|
|
||||||
|
|
||||||
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
|
|
||||||
"tag_deny", `{"tag": "prefix=`+prefixKP+`"}`)
|
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagLimitPercentWorksWithAnIPv6Prefix(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
url := startRouteWatch(t, map[string]standInRoute{fromV6: {prefixV6, 64496}})
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{
|
|
||||||
lookupSource: off, routeWatchURL: url, rateLimitPerMinute: "2",
|
|
||||||
tagLimitPercent: "prefix=" + prefixV6 + ":50",
|
|
||||||
})
|
|
||||||
|
|
||||||
waitForRouteWatch(t, server, fromV6, 64)
|
|
||||||
|
|
||||||
// Half of two is one: the second request breaks the limit.
|
|
||||||
line := s.get(fromV6, http.StatusOK, requestlog.ActionForward)
|
|
||||||
wantTags(t, line, "asn=64496", "prefix="+prefixV6)
|
|
||||||
s.get(fromV6, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
// The heuristic rule_files. It reads the rules of the rule files, which
|
|
||||||
// SWW_RULES_DIR and SWW_RULES_ENABLED name.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ruleFiles checks the request against the rules of the rule files at now,
|
|
||||||
// notes the ids of those it matches in the log line, adds the tag
|
|
||||||
// exploitsignature when it matches any, and refuses the request
|
|
||||||
// when the last rule it matches refuses it: with ActionBlockedByBlockRule for a
|
|
||||||
// block rule, and for a ban rule with the action for the ban it makes. A ban
|
|
||||||
// rule bans the client's netblock for a clear sign of attack, or in observe
|
|
||||||
// mode raises the alert for the ban
|
|
||||||
// it would have made. Either rule's match is noted as an offence, for the
|
|
||||||
// client's history. Its figures are the rule's id and action.
|
|
||||||
type ruleFiles struct{}
|
|
||||||
|
|
||||||
func (ruleFiles) name() string { return "rule_files" }
|
|
||||||
|
|
||||||
func (ruleFiles) check(_ context.Context, rq *request, now time.Time) *verdict {
|
|
||||||
matched := rq.h.rules.Match(rq.in)
|
|
||||||
|
|
||||||
for _, rule := range matched {
|
|
||||||
rq.line.RuleIDs = append(rq.line.RuleIDs, rule.ID)
|
|
||||||
rq.h.metrics.RuleMatched(rule.ID, rule.Action)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(matched) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.addTag(config.ExploitSignatureTag)
|
|
||||||
|
|
||||||
// Only the last rule matched can refuse the request.
|
|
||||||
last := matched[len(matched)-1]
|
|
||||||
figures := map[string]any{"rule_id": last.ID, "rule_action": last.Action}
|
|
||||||
|
|
||||||
switch last.Action {
|
|
||||||
case rules.ActionBlock:
|
|
||||||
rq.blockedByBlockRule = true
|
|
||||||
|
|
||||||
return &verdict{action: requestlog.ActionBlockedByBlockRule, figures: figures}
|
|
||||||
case rules.ActionBan:
|
|
||||||
rq.attack = true
|
|
||||||
|
|
||||||
return &verdict{
|
|
||||||
action: rq.banForAttack(now, bans.Notes{RuleID: last.ID, Target: last.Target}),
|
|
||||||
figures: figures,
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
// The heuristic tag_deny. It reads SWW_TAG_DENY.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"slices"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// tagDeny refuses a request that carries a tag in SWW_TAG_DENY. Its figure is
|
|
||||||
// the first such tag, in sorted order.
|
|
||||||
type tagDeny struct{}
|
|
||||||
|
|
||||||
func (tagDeny) name() string { return "tag_deny" }
|
|
||||||
|
|
||||||
func (tagDeny) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
|
||||||
for _, tag := range rq.line.Tags {
|
|
||||||
if slices.Contains(rq.h.config.TagDeny, tag) {
|
|
||||||
return &verdict{
|
|
||||||
action: requestlog.ActionClientDenied,
|
|
||||||
figures: map[string]any{"tag": tag},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
package discriminator_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTagDenyRefusesALookupTagAndNamesTheTag(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// The setting takes a country code in either case; the request's tag is
|
|
||||||
// in capitals.
|
|
||||||
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "Country=kp"})
|
|
||||||
|
|
||||||
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
|
|
||||||
wantHeuristic(t, line, "tag_deny", `{"tag": "country=KP"}`)
|
|
||||||
wantTags(t, line, "asn=64511", "country=KP")
|
|
||||||
|
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagDenyRefusesAnASNTag(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, _ := startWithLookups(t, map[string]string{tagDeny: "asn=64511"})
|
|
||||||
|
|
||||||
wantHeuristic(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied),
|
|
||||||
"tag_deny", `{"tag": "asn=64511"}`)
|
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagDenyRefusesAListTagBeforeTheRateLimitsCount(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
|
||||||
tagLists: "torexit=" + torList, tagDeny: "torexit", rateLimitPerMinute: "1",
|
|
||||||
})
|
|
||||||
loadLists(t, server, map[string][]string{torList: {fromDE}})
|
|
||||||
|
|
||||||
// Each is refused for its tag: none is counted to break the limit of one.
|
|
||||||
for range 3 {
|
|
||||||
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied),
|
|
||||||
"tag_deny", `{"tag": "torexit"}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagDenyRefusesAMixedCaseZoneByItsLowerCaseTag(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const mixedCaseZone = "DNSBL.example"
|
|
||||||
|
|
||||||
// The zone's tag is in lower case, however SWW_DNSBL_ZONES writes it.
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
|
||||||
dnsblZones: mixedCaseZone, dnsblResolver: noResolver,
|
|
||||||
reputationAction: actionLog, tagDeny: "dnsbl=" + dnsblZone,
|
|
||||||
})
|
|
||||||
loadVerdicts(server, map[string][]string{fromDE: {mixedCaseZone}})
|
|
||||||
|
|
||||||
wantHeuristic(t, s.get(fromDE, http.StatusForbidden, requestlog.ActionClientDenied),
|
|
||||||
"tag_deny", `{"tag": "dnsbl=`+dnsblZone+`"}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagDenyRefusesAnExploitSignatureOfARuleThatOnlyLogs(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
|
||||||
rulesDir: writeRules(t, "noted path log ^/\n"), tagDeny: "exploitsignature",
|
|
||||||
})
|
|
||||||
|
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionClientDenied)
|
|
||||||
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
|
|
||||||
wantTags(t, line, "exploitsignature")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagLimitPercentLowersALimitTheLowestWinning(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
|
||||||
rateLimitPerMinute: fourAMinute,
|
|
||||||
tagLists: "vpn=" + vpnList + ",tor=" + torList,
|
|
||||||
tagLimitPercent: "vpn:50,tor:25",
|
|
||||||
})
|
|
||||||
loadLists(t, server, map[string][]string{vpnList: {fromDE}, torList: {fromDE}})
|
|
||||||
|
|
||||||
// A quarter of 4 requests a minute: the second breaks the limit.
|
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
||||||
|
|
||||||
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
|
||||||
"25 from "+tagLimitPercent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagLimitPercentLowersAByteLimitByAListTag(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
|
||||||
bytesLimitPerMinute: twoUploads,
|
|
||||||
tagLists: "vpn=" + vpnList,
|
|
||||||
tagLimitPercent: "vpn:50",
|
|
||||||
})
|
|
||||||
loadLists(t, server, map[string][]string{vpnList: {fromDE}})
|
|
||||||
|
|
||||||
// The upload's 100 bytes are over half of 199, 99.
|
|
||||||
line := s.uploadFrom(fromDE)
|
|
||||||
if line.LimitHit != minuteBytes {
|
|
||||||
t.Errorf("log line has limit_hit %q, want %s", line.LimitHit, minuteBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting,
|
|
||||||
"50 from "+tagLimitPercent)
|
|
||||||
|
|
||||||
// fromKP, which the list does not hold, has the whole limit.
|
|
||||||
line = s.uploadFrom(fromKP)
|
|
||||||
if line.LimitHit != "" {
|
|
||||||
t.Errorf("log line for %s has limit_hit %q, want none", fromKP, line.LimitHit)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantPercent(t, "bytes_percent", line.BytesPercent, line.BytesPercentSetting, none)
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
// The heuristic tag_lists. It reads the address lists of SWW_TAG_LISTS through
|
|
||||||
// the reputation lists.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// tagLists adds to the request one tag for each list of SWW_TAG_LISTS that
|
|
||||||
// holds the client, the tag being the list's name. It refuses nothing, and runs
|
|
||||||
// first so that every refusal's log line carries these tags.
|
|
||||||
type tagLists struct{}
|
|
||||||
|
|
||||||
func (tagLists) name() string { return "tag_lists" }
|
|
||||||
|
|
||||||
func (tagLists) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
|
||||||
for _, name := range rq.h.lists.TagsOf(rq.client) {
|
|
||||||
rq.addTag(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package discriminator_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// tagLists is SWW_TAG_LISTS, and vpnList and torList lists it names.
|
|
||||||
const (
|
|
||||||
tagLists = "SWW_TAG_LISTS"
|
|
||||||
vpnList = "https://lists.example/vpn.txt"
|
|
||||||
torList = "https://lists.example/torexit.txt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// wantTags checks the log line's tags.
|
|
||||||
func wantTags(t *testing.T, line logLine, want ...string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
got, _ := line.fields["tags"].([]any)
|
|
||||||
|
|
||||||
tags := make([]string, 0, len(got))
|
|
||||||
for _, tag := range got {
|
|
||||||
text, _ := tag.(string)
|
|
||||||
tags = append(tags, text)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !slices.Equal(tags, want) {
|
|
||||||
t.Errorf("log line has tags %v, want %v", tags, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLookupAndTagListsGiveTheRequestItsTagsSorted(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
|
||||||
tagLists: "vpn=mullvad=" + vpnList + ",TorExit=" + torList,
|
|
||||||
})
|
|
||||||
loadLists(t, server, map[string][]string{
|
|
||||||
vpnList: {fromDE}, torList: {fromDE + "/32", fromKP},
|
|
||||||
})
|
|
||||||
|
|
||||||
wantTags(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
|
||||||
"asn=64496", "country=DE", "torexit", "vpn=mullvad")
|
|
||||||
wantTags(t, s.get(fromKP, http.StatusOK, requestlog.ActionForward),
|
|
||||||
"asn=64511", "country=KP", "torexit")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequestWithoutATagLeavesTheFieldOut(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// With no lookup, a client is not placed and no setting gives it a tag.
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{lookupSource: "off"})
|
|
||||||
|
|
||||||
line := s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
||||||
if _, present := line.fields["tags"]; present {
|
|
||||||
t.Errorf("log line has tags %v, want none", line.fields["tags"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagListsTagEvenARequestTheCountryListRefuses(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
|
||||||
tagLists: "vpn=" + vpnList, allowedCountries: "de",
|
|
||||||
})
|
|
||||||
loadLists(t, server, map[string][]string{vpnList: {fromKP}})
|
|
||||||
|
|
||||||
wantTags(t, s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
|
|
||||||
"asn=64511", "country=KP", "vpn")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTagListsTagARequestRefusedBeforeTheLookup(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, server, _ := startWithLookups(t, map[string]string{
|
|
||||||
tagLists: "vpn=" + vpnList, denyNets: fromKP, rateLimitPerMinute: "1",
|
|
||||||
})
|
|
||||||
loadLists(t, server, map[string][]string{vpnList: {fromDE, fromKP}})
|
|
||||||
|
|
||||||
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionClientDenied)
|
|
||||||
if line.Heuristic != "deny_nets" {
|
|
||||||
t.Errorf("log line has heuristic %q, want deny_nets", line.Heuristic)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantTags(t, line, "vpn")
|
|
||||||
|
|
||||||
// The second request breaks the limit of one a minute, which bans the client.
|
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
|
||||||
|
|
||||||
line = s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
|
||||||
if line.Heuristic != "active_ban" {
|
|
||||||
t.Errorf("log line has heuristic %q, want active_ban", line.Heuristic)
|
|
||||||
}
|
|
||||||
|
|
||||||
wantTags(t, line, "vpn")
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package discriminator
|
|
||||||
|
|
||||||
// The heuristic trap_paths. It reads SWW_TRAP_PATHS.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"slices"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
|
||||||
)
|
|
||||||
|
|
||||||
// trapPaths refuses a request for a path in SWW_TRAP_PATHS: its path as a
|
|
||||||
// path rule sees it, without the query, as sent or once percent-decoded, is one
|
|
||||||
// of them. Such a request is a clear sign of attack, as a ban rule's match is:
|
|
||||||
// it bans the client's netblock, or in observe mode raises the alert for the
|
|
||||||
// ban it would have made. The ban's notes give the path as sent, and so does
|
|
||||||
// its figure.
|
|
||||||
type trapPaths struct{}
|
|
||||||
|
|
||||||
func (trapPaths) name() string { return "trap_paths" }
|
|
||||||
|
|
||||||
func (trapPaths) check(_ context.Context, rq *request, now time.Time) *verdict {
|
|
||||||
path := rules.Path(rq.in)
|
|
||||||
if !slices.Contains(rq.h.config.TrapPaths, path) &&
|
|
||||||
!slices.Contains(rq.h.config.TrapPaths, rules.DecodeOnce(path)) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rq.attack = true
|
|
||||||
|
|
||||||
return &verdict{
|
|
||||||
action: rq.banForAttack(now, bans.Notes{TrapPath: path}),
|
|
||||||
figures: map[string]any{"path": path},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+8
-18
@@ -24,7 +24,7 @@ const quietTime = 2 * time.Second
|
|||||||
// FileParams are what OpenFile needs.
|
// FileParams are what OpenFile needs.
|
||||||
type FileParams struct {
|
type FileParams struct {
|
||||||
// Path is the lookup database, the IPinfo Lite file in its .mmdb form
|
// Path is the lookup database, the IPinfo Lite file in its .mmdb form
|
||||||
// (SWW_LOOKUP_DB_PATH).
|
// (SWWAF_LOOKUP_DB_PATH).
|
||||||
Path string
|
Path string
|
||||||
// Now tells the time, normally time.Now.
|
// Now tells the time, normally time.Now.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
@@ -51,7 +51,7 @@ type File struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// record is what the lookup database holds about a network, of the fields
|
// record is what the lookup database holds about a network, of the fields
|
||||||
// sww reads.
|
// smallwebwaf reads.
|
||||||
type record struct {
|
type record struct {
|
||||||
ASN string `maxminddb:"asn"`
|
ASN string `maxminddb:"asn"`
|
||||||
ASName string `maxminddb:"as_name"`
|
ASName string `maxminddb:"as_name"`
|
||||||
@@ -133,11 +133,8 @@ func (f *File) Watch(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.params.ProcessLog.Error(
|
f.params.ProcessLog.Error("cannot watch the lookup database for replacements",
|
||||||
"cannot watch the lookup database for replacements",
|
"error", err.Error())
|
||||||
"error",
|
|
||||||
err.Error(),
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -197,10 +194,7 @@ func (f *File) readAgain() {
|
|||||||
f.params.Alerts.Raise(alerts.Alert{
|
f.params.Alerts.Raise(alerts.Alert{
|
||||||
Event: alerts.EventFileError,
|
Event: alerts.EventFileError,
|
||||||
Reason: kept,
|
Reason: kept,
|
||||||
Detail: map[string]any{
|
Detail: map[string]any{"file": f.params.Path, "error": err.Error()},
|
||||||
"file": f.params.Path,
|
|
||||||
"error": err.Error(),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
f.params.ProcessLog.Error(kept, "error", err.Error())
|
f.params.ProcessLog.Error(kept, "error", err.Error())
|
||||||
|
|
||||||
@@ -218,11 +212,7 @@ func (f *File) use(reader *maxminddb.Reader) {
|
|||||||
f.lastRead = f.params.Now()
|
f.lastRead = f.params.Now()
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
|
|
||||||
f.params.ProcessLog.Info(
|
f.params.ProcessLog.Info("read the lookup database", "file", f.params.Path)
|
||||||
"read the lookup database",
|
|
||||||
"file",
|
|
||||||
f.params.Path,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// read reads the lookup database at path. The whole file is read into
|
// read reads the lookup database at path. The whole file is read into
|
||||||
@@ -231,12 +221,12 @@ func (f *File) use(reader *maxminddb.Reader) {
|
|||||||
func read(path string) (*maxminddb.Reader, error) {
|
func read(path string) (*maxminddb.Reader, error) {
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
|
data, err := os.ReadFile(path) //nolint:gosec // the file the admin names
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH cannot be read: %w", err)
|
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH cannot be read: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
reader, err := maxminddb.OpenBytes(data)
|
reader, err := maxminddb.OpenBytes(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWW_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
|
return nil, fmt.Errorf("SWWAF_LOOKUP_DB_PATH %s is not a .mmdb file: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return reader, nil
|
return reader, nil
|
||||||
|
|||||||
@@ -99,9 +99,9 @@ func TestFileThatCannotBeReadIsAnError(t *testing.T) {
|
|||||||
writeFile(t, notDatabase, "not a lookup database\n")
|
writeFile(t, notDatabase, "not a lookup database\n")
|
||||||
|
|
||||||
for path, want := range map[string]string{
|
for path, want := range map[string]string{
|
||||||
missing: "SWW_LOOKUP_DB_PATH cannot be read: open " + missing +
|
missing: "SWWAF_LOOKUP_DB_PATH cannot be read: open " + missing +
|
||||||
": no such file or directory",
|
": no such file or directory",
|
||||||
notDatabase: "SWW_LOOKUP_DB_PATH " + notDatabase +
|
notDatabase: "SWWAF_LOOKUP_DB_PATH " + notDatabase +
|
||||||
" is not a .mmdb file: error opening database: invalid MaxMind DB file",
|
" is not a .mmdb file: error opening database: invalid MaxMind DB file",
|
||||||
} {
|
} {
|
||||||
_, err := OpenFile(FileParams{
|
_, err := OpenFile(FileParams{
|
||||||
@@ -216,7 +216,7 @@ func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(t *testing.T)
|
|||||||
Reason: "the lookup database cannot be read, and the one read before stays in use",
|
Reason: "the lookup database cannot be read, and the one read before stays in use",
|
||||||
Detail: map[string]any{
|
Detail: map[string]any{
|
||||||
"file": path,
|
"file": path,
|
||||||
"error": "SWW_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
|
"error": "SWWAF_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " +
|
||||||
"error opening database: invalid MaxMind DB file",
|
"error opening database: invalid MaxMind DB file",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Package lookup looks up each client's AS number and country, through
|
// Package lookup looks up each client's AS number and country, through
|
||||||
// the GeoJS web service or in the lookup database, the IPinfo Lite file
|
// the GeoJS web service or in the lookup database, the IPinfo Lite file
|
||||||
// SWW_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
|
// SWWAF_LOOKUP_DB_PATH names. GeoJS's answers are kept in memory, for at
|
||||||
// most 100,000 clients and for 7 days each, and are written to
|
// most 100,000 clients and for 7 days each, and are written to
|
||||||
// lookups.json and read from it by the state package.
|
// lookups.json and read from it by the state package.
|
||||||
package lookup
|
package lookup
|
||||||
@@ -66,7 +66,7 @@ type Params struct {
|
|||||||
URL string
|
URL string
|
||||||
// Timeout is how long a request waits for its client's first answer,
|
// Timeout is how long a request waits for its client's first answer,
|
||||||
// and how long a request to GeoJS may take before it is abandoned
|
// and how long a request to GeoJS may take before it is abandoned
|
||||||
// (SWW_LOOKUP_TIMEOUT).
|
// (SWWAF_LOOKUP_TIMEOUT).
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Wait is true when a setting needs each request's answer before the
|
// Wait is true when a setting needs each request's answer before the
|
||||||
// request goes on. Otherwise no request waits for one.
|
// request goes on. Otherwise no request waits for one.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
const fileMode = 0o600
|
const fileMode = 0o600
|
||||||
|
|
||||||
// Network is what a lookup database holds about a netblock, of the fields
|
// Network is what a lookup database holds about a netblock, of the fields
|
||||||
// sww reads: its AS number, such as AS64496, the AS's name, and
|
// smallwebwaf reads: its AS number, such as AS64496, the AS's name, and
|
||||||
// its country, such as DE.
|
// its country, such as DE.
|
||||||
type Network struct {
|
type Network struct {
|
||||||
ASN string
|
ASN string
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ type busiest struct {
|
|||||||
requests *prometheus.CounterVec
|
requests *prometheus.CounterVec
|
||||||
requestBytes *prometheus.CounterVec
|
requestBytes *prometheus.CounterVec
|
||||||
responseBytes *prometheus.CounterVec
|
responseBytes *prometheus.CounterVec
|
||||||
// refused are, by country, the requests the country list refused; nil
|
// refused are, by country, the requests the country lists refused; nil
|
||||||
// by AS number.
|
// by AS number.
|
||||||
refused *prometheus.CounterVec
|
refused *prometheus.CounterVec
|
||||||
|
|
||||||
@@ -41,8 +41,8 @@ type busiest struct {
|
|||||||
// for the topN busiest countries.
|
// for the topN busiest countries.
|
||||||
func newCountries(topN int) *busiest {
|
func newCountries(topN int) *busiest {
|
||||||
countries := newBusiest(topN, "country", "the client's country")
|
countries := newBusiest(topN, "country", "the client's country")
|
||||||
countries.refused = counterVec("sww_country_list_refusals_total",
|
countries.refused = counterVec("smallwebwaf_country_list_refusals_total",
|
||||||
"Requests SWW_EXCLUSIVELY_ALLOWED_COUNTRIES refused, by the client's country.",
|
"Requests the country lists refused, by the client's country.",
|
||||||
[]string{"country"})
|
[]string{"country"})
|
||||||
|
|
||||||
return countries
|
return countries
|
||||||
@@ -61,11 +61,11 @@ func newBusiest(topN int, label, description string) *busiest {
|
|||||||
|
|
||||||
return &busiest{
|
return &busiest{
|
||||||
topN: topN,
|
topN: topN,
|
||||||
requests: counterVec("sww_"+label+"_requests_total",
|
requests: counterVec("smallwebwaf_"+label+"_requests_total",
|
||||||
"Requests, by "+description+".", by),
|
"Requests, by "+description+".", by),
|
||||||
requestBytes: counterVec("sww_"+label+"_request_bytes_total",
|
requestBytes: counterVec("smallwebwaf_"+label+"_request_bytes_total",
|
||||||
"Request body bytes, by "+description+".", by),
|
"Request body bytes, by "+description+".", by),
|
||||||
responseBytes: counterVec("sww_"+label+"_response_bytes_total",
|
responseBytes: counterVec("smallwebwaf_"+label+"_response_bytes_total",
|
||||||
"Response body bytes, by "+description+".", by),
|
"Response body bytes, by "+description+".", by),
|
||||||
seen: map[string]int64{},
|
seen: map[string]int64{},
|
||||||
top: map[string]bool{},
|
top: map[string]bool{},
|
||||||
@@ -88,7 +88,7 @@ func (b *busiest) Collect(ch chan<- prometheus.Metric) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// vecs returns the metrics: by AS number, those of requests and bytes; by
|
// vecs returns the metrics: by AS number, those of requests and bytes; by
|
||||||
// country, the refusals by the country list as well.
|
// country, the refusals by the country lists as well.
|
||||||
func (b *busiest) vecs() []*prometheus.CounterVec {
|
func (b *busiest) vecs() []*prometheus.CounterVec {
|
||||||
vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes}
|
vecs := []*prometheus.CounterVec{b.requests, b.requestBytes, b.responseBytes}
|
||||||
if b.refused != nil {
|
if b.refused != nil {
|
||||||
|
|||||||
+55
-55
@@ -1,4 +1,4 @@
|
|||||||
// Package metrics keeps sww's Prometheus metrics, as
|
// Package metrics keeps smallwebwaf's Prometheus metrics, as
|
||||||
// docs/metrics.md lists them, and serves them in the Prometheus text
|
// docs/metrics.md lists them, and serves them in the Prometheus text
|
||||||
// format, as the "Metrics endpoint" section of SPEC.md requires. No
|
// format, as the "Metrics endpoint" section of SPEC.md requires. No
|
||||||
// metric carries a client's address.
|
// metric carries a client's address.
|
||||||
@@ -22,7 +22,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Metrics are sww's metrics. They are safe for concurrent use.
|
// Metrics are smallwebwaf's metrics. They are safe for concurrent use.
|
||||||
type Metrics struct {
|
type Metrics struct {
|
||||||
// registry gives every metric registered with it the label instance.
|
// registry gives every metric registered with it the label instance.
|
||||||
registry prometheus.Registerer
|
registry prometheus.Registerer
|
||||||
@@ -62,8 +62,8 @@ type Metrics struct {
|
|||||||
|
|
||||||
// New returns the metrics, with the Go runtime's and the process's own.
|
// New returns the metrics, with the Go runtime's and the process's own.
|
||||||
// topN is how many countries and how many AS numbers get series of their
|
// topN is how many countries and how many AS numbers get series of their
|
||||||
// own (SWW_METRICS_TOP_N). Every metric carries instanceName
|
// own (SWWAF_METRICS_TOP_N). Every metric carries instanceName
|
||||||
// (SWW_INSTANCE_NAME) as its label instance.
|
// (SWWAF_INSTANCE_NAME) as its label instance.
|
||||||
//
|
//
|
||||||
//nolint:funlen // a few lines for each metric, a list that grows with them
|
//nolint:funlen // a few lines for each metric, a list that grows with them
|
||||||
func New(topN int, instanceName string) *Metrics {
|
func New(topN int, instanceName string) *Metrics {
|
||||||
@@ -76,60 +76,60 @@ func New(topN int, instanceName string) *Metrics {
|
|||||||
prometheus.Labels{"instance": instanceName}, registry),
|
prometheus.Labels{"instance": instanceName}, registry),
|
||||||
handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
|
handler: promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
|
||||||
inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
|
inFlight: prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
Name: "sww_requests_in_flight",
|
Name: "smallwebwaf_requests_in_flight",
|
||||||
Help: "Requests under way.",
|
Help: "Requests under way.",
|
||||||
}),
|
}),
|
||||||
requests: counterVec("sww_requests_total",
|
requests: counterVec("smallwebwaf_requests_total",
|
||||||
"Requests, by the class of their status and their action.", byStatus),
|
"Requests, by the class of their status and their action.", byStatus),
|
||||||
requestBytes: counterVec("sww_request_bytes_total",
|
requestBytes: counterVec("smallwebwaf_request_bytes_total",
|
||||||
"Request body bytes, by the class of the status and the action.",
|
"Request body bytes, by the class of the status and the action.",
|
||||||
byStatus),
|
byStatus),
|
||||||
responseBytes: counterVec("sww_response_bytes_total",
|
responseBytes: counterVec("smallwebwaf_response_bytes_total",
|
||||||
"Response body bytes, by the class of the status and the action.",
|
"Response body bytes, by the class of the status and the action.",
|
||||||
byStatus),
|
byStatus),
|
||||||
requestDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
requestDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||||
Name: "sww_request_duration_seconds",
|
Name: "smallwebwaf_request_duration_seconds",
|
||||||
Help: "How long requests took, from their arrival to their end.",
|
Help: "How long requests took, from their arrival to their end.",
|
||||||
}),
|
}),
|
||||||
upstreamDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
upstreamDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
|
||||||
Name: "sww_upstream_duration_seconds",
|
Name: "smallwebwaf_upstream_duration_seconds",
|
||||||
Help: "How long requests passed to the app took, from then to their end.",
|
Help: "How long requests passed to the app took, from then to their end.",
|
||||||
}),
|
}),
|
||||||
rateLimitHits: counterVec("sww_rate_limit_hits_total",
|
rateLimitHits: counterVec("smallwebwaf_rate_limit_hits_total",
|
||||||
"Requests that broke a rate limit, a byte limit or the error burst, by "+
|
"Requests that broke a rate limit, a byte limit or the error burst, by "+
|
||||||
"its window and its kind, requests, bytes or refusals.",
|
"its window and its kind, requests, bytes or refusals.",
|
||||||
[]string{"window", "kind"}),
|
[]string{"window", "kind"}),
|
||||||
offences: counterVec("sww_offences_total",
|
offences: counterVec("smallwebwaf_offences_total",
|
||||||
"Offences, by kind.", []string{"kind"}),
|
"Offences, by kind.", []string{"kind"}),
|
||||||
wafMatches: counterVec("sww_waf_matches_total",
|
wafMatches: counterVec("smallwebwaf_waf_matches_total",
|
||||||
"Requests that matched a rule of the Core Rule Set, by SWW_WAF_MODE "+
|
"Requests that matched a rule of the Core Rule Set, by SWWAF_WAF_MODE "+
|
||||||
"and the rule's id.", []string{"mode", "rule_id"}),
|
"and the rule's id.", []string{"mode", "rule_id"}),
|
||||||
countries: newCountries(topN),
|
countries: newCountries(topN),
|
||||||
asns: newASNs(topN),
|
asns: newASNs(topN),
|
||||||
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
GeoJSRequests: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "sww_geojs_requests_total",
|
Name: "smallwebwaf_geojs_requests_total",
|
||||||
Help: "Requests to GeoJS.",
|
Help: "Requests to GeoJS.",
|
||||||
}),
|
}),
|
||||||
GeoJSFailures: prometheus.NewCounter(prometheus.CounterOpts{
|
GeoJSFailures: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "sww_geojs_failures_total",
|
Name: "smallwebwaf_geojs_failures_total",
|
||||||
Help: "Requests to GeoJS that failed.",
|
Help: "Requests to GeoJS that failed.",
|
||||||
}),
|
}),
|
||||||
GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{
|
GeoJSUnanswered: prometheus.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "sww_geojs_unanswered_total",
|
Name: "smallwebwaf_geojs_unanswered_total",
|
||||||
Help: "Requests that needed their client's answer from GeoJS and " +
|
Help: "Requests that needed their client's answer from GeoJS and " +
|
||||||
"went on without it, because GeoJS had not given it in time.",
|
"went on without it, because GeoJS had not given it in time.",
|
||||||
}),
|
}),
|
||||||
stateFileWrites: counterVec("sww_state_file_writes_total",
|
stateFileWrites: counterVec("smallwebwaf_state_file_writes_total",
|
||||||
"Writes of each state file.", byFile),
|
"Writes of each state file.", byFile),
|
||||||
stateFileWriteFailures: counterVec("sww_state_file_write_failures_total",
|
stateFileWriteFailures: counterVec("smallwebwaf_state_file_write_failures_total",
|
||||||
"Writes of each state file that failed.", byFile),
|
"Writes of each state file that failed.", byFile),
|
||||||
stateFileLastWrite: gaugeVec("sww_state_file_last_write_timestamp_seconds",
|
stateFileLastWrite: gaugeVec("smallwebwaf_state_file_last_write_timestamp_seconds",
|
||||||
"When each state file was last written, in seconds since 1970.", byFile),
|
"When each state file was last written, in seconds since 1970.", byFile),
|
||||||
stateFileSize: gaugeVec("sww_state_file_size_bytes",
|
stateFileSize: gaugeVec("smallwebwaf_state_file_size_bytes",
|
||||||
"The size of each state file, as it was last written.", byFile),
|
"The size of each state file, as it was last written.", byFile),
|
||||||
stateFileEditsTakenIn: counterVec("sww_state_file_edits_taken_in_total",
|
stateFileEditsTakenIn: counterVec("smallwebwaf_state_file_edits_taken_in_total",
|
||||||
"Edits of each state file taken in while running.", byFile),
|
"Edits of each state file taken in while running.", byFile),
|
||||||
stateFileEditsSetAside: counterVec("sww_state_file_edits_set_aside_total",
|
stateFileEditsSetAside: counterVec("smallwebwaf_state_file_edits_set_aside_total",
|
||||||
"Edits of each state file renamed to <name>.bad because they did not parse.",
|
"Edits of each state file renamed to <name>.bad because they did not parse.",
|
||||||
byFile),
|
byFile),
|
||||||
}
|
}
|
||||||
@@ -161,7 +161,7 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin, bans.CauseCrowdSec,
|
bans.CauseLimit, bans.CauseAttack, bans.CauseAdmin, bans.CauseCrowdSec,
|
||||||
} {
|
} {
|
||||||
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "sww_bans_made_total",
|
Name: "smallwebwaf_bans_made_total",
|
||||||
Help: "Bans made, by cause.",
|
Help: "Bans made, by cause.",
|
||||||
ConstLabels: prometheus.Labels{"cause": cause},
|
ConstLabels: prometheus.Labels{"cause": cause},
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
@@ -171,7 +171,7 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
|
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "sww_active_bans",
|
Name: "smallwebwaf_active_bans",
|
||||||
Help: "Bans active now, the permanent ones included.",
|
Help: "Bans active now, the permanent ones included.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
active, _ := ledger.Count(now())
|
active, _ := ledger.Count(now())
|
||||||
@@ -179,7 +179,7 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
return float64(active)
|
return float64(active)
|
||||||
}),
|
}),
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "sww_permanent_bans",
|
Name: "smallwebwaf_permanent_bans",
|
||||||
Help: "Permanent bans not lifted.",
|
Help: "Permanent bans not lifted.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
_, permanent := ledger.Count(now())
|
_, permanent := ledger.Count(now())
|
||||||
@@ -187,7 +187,7 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
return float64(permanent)
|
return float64(permanent)
|
||||||
}),
|
}),
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "sww_tracked_clients",
|
Name: "smallwebwaf_tracked_clients",
|
||||||
Help: "Clients in the table of clients.",
|
Help: "Clients in the table of clients.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(limiter.Len())
|
return float64(limiter.Len())
|
||||||
@@ -200,13 +200,13 @@ func (m *Metrics) AddBansAndClients(
|
|||||||
// ruleFiles, read as the metrics are asked for. It is called once, before
|
// ruleFiles, read as the metrics are asked for. It is called once, before
|
||||||
// RuleMatched.
|
// RuleMatched.
|
||||||
func (m *Metrics) AddRules(ruleFiles *rules.Files) {
|
func (m *Metrics) AddRules(ruleFiles *rules.Files) {
|
||||||
m.ruleMatches = counterVec("sww_rule_matches_total",
|
m.ruleMatches = counterVec("smallwebwaf_rule_matches_total",
|
||||||
"Requests that matched a rule of the rule files, by its id and action.",
|
"Requests that matched a rule of the rule files, by its id and action.",
|
||||||
[]string{"rule_id", "action"})
|
[]string{"rule_id", "action"})
|
||||||
|
|
||||||
m.registry.MustRegister(m.ruleMatches,
|
m.registry.MustRegister(m.ruleMatches,
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "sww_rules_loaded",
|
Name: "smallwebwaf_rules_loaded",
|
||||||
Help: "Rules loaded from the rule files.",
|
Help: "Rules loaded from the rule files.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(ruleFiles.Len())
|
return float64(ruleFiles.Len())
|
||||||
@@ -214,25 +214,25 @@ func (m *Metrics) AddRules(ruleFiles *rules.Files) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddRemoteLog adds the metrics of sending the log lines to
|
// AddRemoteLog adds the metrics of sending the log lines to
|
||||||
// SWW_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
|
// SWWAF_LOG_REMOTE_URL, read from remote as the metrics are asked for: the
|
||||||
// lines sent, those dropped, and those waiting in the buffer.
|
// lines sent, those dropped, and those waiting in the buffer.
|
||||||
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "sww_remote_log_lines_sent_total",
|
Name: "smallwebwaf_remote_log_lines_sent_total",
|
||||||
Help: "Log lines sent to SWW_LOG_REMOTE_URL.",
|
Help: "Log lines sent to SWWAF_LOG_REMOTE_URL.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(remote.Sent())
|
return float64(remote.Sent())
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "sww_remote_log_lines_dropped_total",
|
Name: "smallwebwaf_remote_log_lines_dropped_total",
|
||||||
Help: "Log lines dropped: the oldest in a full buffer, and those " +
|
Help: "Log lines dropped: the oldest in a full buffer, and those " +
|
||||||
"whose sending failed.",
|
"whose sending failed.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(remote.Dropped())
|
return float64(remote.Dropped())
|
||||||
}),
|
}),
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "sww_remote_log_buffer_depth",
|
Name: "smallwebwaf_remote_log_buffer_depth",
|
||||||
Help: "Log lines in the buffer, waiting to be sent.",
|
Help: "Log lines in the buffer, waiting to be sent.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(remote.Depth())
|
return float64(remote.Depth())
|
||||||
@@ -248,13 +248,13 @@ func (m *Metrics) AddRemoteLog(remote *remotelog.Sender) {
|
|||||||
func (m *Metrics) AddLookupFile(lastRead func() time.Time, readFailures func() int) {
|
func (m *Metrics) AddLookupFile(lastRead func() time.Time, readFailures func() int) {
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "sww_lookup_database_last_read_timestamp_seconds",
|
Name: "smallwebwaf_lookup_database_last_read_timestamp_seconds",
|
||||||
Help: "When the lookup database in use was read, in seconds since 1970.",
|
Help: "When the lookup database in use was read, in seconds since 1970.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(lastRead().Unix())
|
return float64(lastRead().Unix())
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "sww_lookup_database_read_failures_total",
|
Name: "smallwebwaf_lookup_database_read_failures_total",
|
||||||
Help: "Replacements of the lookup database that could not be read.",
|
Help: "Replacements of the lookup database that could not be read.",
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(readFailures())
|
return float64(readFailures())
|
||||||
@@ -275,7 +275,7 @@ const sourceLabel = "source"
|
|||||||
// copy in use was fetched, and for a zone, the queries made and those that
|
// copy in use was fetched, and for a zone, the queries made and those that
|
||||||
// failed. It is called once, before ReputationHit.
|
// failed. It is called once, before ReputationHit.
|
||||||
func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) {
|
func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL) {
|
||||||
m.reputationHits = counterVec("sww_reputation_hits_total",
|
m.reputationHits = counterVec("smallwebwaf_reputation_hits_total",
|
||||||
"Requests whose client a blocklist, the CrowdSec decision list, a DNSBL "+
|
"Requests whose client a blocklist, the CrowdSec decision list, a DNSBL "+
|
||||||
"zone or AbuseIPDB lists, by the list's URL, the zone, or abuseipdb.",
|
"zone or AbuseIPDB lists, by the list's URL, the zone, or abuseipdb.",
|
||||||
[]string{sourceLabel})
|
[]string{sourceLabel})
|
||||||
@@ -294,7 +294,7 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL
|
|||||||
m.addReputationFailures(source, func() int { return lists.Failures(listURL) })
|
m.addReputationFailures(source, func() int { return lists.Failures(listURL) })
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "sww_reputation_last_fetch_timestamp_seconds",
|
Name: "smallwebwaf_reputation_last_fetch_timestamp_seconds",
|
||||||
Help: "When the copy of the list in use was fetched, in seconds since " +
|
Help: "When the copy of the list in use was fetched, in seconds since " +
|
||||||
"1970, or 0 while there is none.",
|
"1970, or 0 while there is none.",
|
||||||
ConstLabels: source,
|
ConstLabels: source,
|
||||||
@@ -313,7 +313,7 @@ func (m *Metrics) AddReputation(lists *reputation.Lists, dnsbl *reputation.DNSBL
|
|||||||
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
|
// AddAbuseIPDB adds the metrics of AbuseIPDB, with the source abuseipdb,
|
||||||
// read from abuseIPDB as the metrics are asked for: the checks made, those
|
// read from abuseIPDB as the metrics are asked for: the checks made, those
|
||||||
// that failed, and how many checks the day's budget has left. It is
|
// that failed, and how many checks the day's budget has left. It is
|
||||||
// called once, after AddReputation, while SWW_ABUSEIPDB_KEY is set.
|
// called once, after AddReputation, while SWWAF_ABUSEIPDB_KEY is set.
|
||||||
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
||||||
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
|
source := prometheus.Labels{sourceLabel: reputation.AbuseIPDBSource}
|
||||||
|
|
||||||
@@ -321,8 +321,8 @@ func (m *Metrics) AddAbuseIPDB(abuseIPDB *reputation.AbuseIPDB) {
|
|||||||
m.addReputationFailures(source, abuseIPDB.Failures)
|
m.addReputationFailures(source, abuseIPDB.Failures)
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
Name: "sww_reputation_daily_budget_remaining",
|
Name: "smallwebwaf_reputation_daily_budget_remaining",
|
||||||
Help: "Checks of the day's SWW_ABUSEIPDB_DAILY_BUDGET not yet spent.",
|
Help: "Checks of the day's SWWAF_ABUSEIPDB_DAILY_BUDGET not yet spent.",
|
||||||
ConstLabels: source,
|
ConstLabels: source,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(abuseIPDB.BudgetLeft())
|
return float64(abuseIPDB.BudgetLeft())
|
||||||
@@ -348,29 +348,29 @@ func (m *Metrics) AddAlerts(queue *alerts.Queue) {
|
|||||||
|
|
||||||
m.registry.MustRegister(
|
m.registry.MustRegister(
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "sww_alerts_sent_total",
|
Name: "smallwebwaf_alerts_sent_total",
|
||||||
Help: "Alerts the destination took.",
|
Help: "Alerts the destination took.",
|
||||||
ConstLabels: destination,
|
ConstLabels: destination,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(queue.Counts(name).Sent)
|
return float64(queue.Counts(name).Sent)
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "sww_alerts_failed_total",
|
Name: "smallwebwaf_alerts_failed_total",
|
||||||
Help: "Requests to the destination that failed.",
|
Help: "Requests to the destination that failed.",
|
||||||
ConstLabels: destination,
|
ConstLabels: destination,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(queue.Counts(name).Failed)
|
return float64(queue.Counts(name).Failed)
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "sww_alerts_suppressed_total",
|
Name: "smallwebwaf_alerts_suppressed_total",
|
||||||
Help: "Alerts held back: repeats within SWW_ALERT_COOLDOWN, and " +
|
Help: "Alerts held back: repeats within SWWAF_ALERT_COOLDOWN, and " +
|
||||||
"alerts past SWW_ALERT_MAX_PER_HOUR, for the hour's summary.",
|
"alerts past SWWAF_ALERT_MAX_PER_HOUR, for the hour's summary.",
|
||||||
ConstLabels: destination,
|
ConstLabels: destination,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
return float64(queue.Suppressed())
|
return float64(queue.Suppressed())
|
||||||
}),
|
}),
|
||||||
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "sww_alerts_dropped_total",
|
Name: "smallwebwaf_alerts_dropped_total",
|
||||||
Help: "Alerts dropped, the oldest first, from a full queue, and " +
|
Help: "Alerts dropped, the oldest first, from a full queue, and " +
|
||||||
"alerts given up as the destination refused them.",
|
"alerts given up as the destination refused them.",
|
||||||
ConstLabels: destination,
|
ConstLabels: destination,
|
||||||
@@ -424,10 +424,10 @@ func (m *Metrics) LimitHit(hit ratelimit.Hit) {
|
|||||||
// them.
|
// them.
|
||||||
func (m *Metrics) Offences(r ratelimit.Request) {
|
func (m *Metrics) Offences(r ratelimit.Request) {
|
||||||
for kind, committed := range map[string]bool{
|
for kind, committed := range map[string]bool{
|
||||||
"limit": r.BrokeLimit,
|
"limit": r.BrokeLimit,
|
||||||
"attack": r.Attack,
|
"attack": r.Attack,
|
||||||
"blocked_by_block_rule": r.BlockedByBlockRule,
|
"rule_blocked": r.RuleBlocked,
|
||||||
"waf_blocked": r.WAFBlocked,
|
"waf_blocked": r.WAFBlocked,
|
||||||
} {
|
} {
|
||||||
if committed {
|
if committed {
|
||||||
m.offences.WithLabelValues(kind).Inc()
|
m.offences.WithLabelValues(kind).Inc()
|
||||||
@@ -442,7 +442,7 @@ func (m *Metrics) RuleMatched(id, action string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WAFMatched counts a request that matched the Core Rule Set's rule id,
|
// WAFMatched counts a request that matched the Core Rule Set's rule id,
|
||||||
// with SWW_WAF_MODE at mode.
|
// with SWWAF_WAF_MODE at mode.
|
||||||
func (m *Metrics) WAFMatched(mode string, id int) {
|
func (m *Metrics) WAFMatched(mode string, id int) {
|
||||||
m.wafMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
|
m.wafMatches.WithLabelValues(mode, strconv.Itoa(id)).Inc()
|
||||||
}
|
}
|
||||||
@@ -467,7 +467,7 @@ func (m *Metrics) StateFileWritten(name string, size int, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// StateFileEditTakenIn counts an admin's edit of the state file name
|
// StateFileEditTakenIn counts an admin's edit of the state file name
|
||||||
// taken in while sww runs.
|
// taken in while smallwebwaf runs.
|
||||||
func (m *Metrics) StateFileEditTakenIn(name string) {
|
func (m *Metrics) StateFileEditTakenIn(name string) {
|
||||||
m.stateFileEditsTakenIn.WithLabelValues(name).Inc()
|
m.stateFileEditsTakenIn.WithLabelValues(name).Inc()
|
||||||
}
|
}
|
||||||
@@ -506,7 +506,7 @@ func (m *Metrics) count(
|
|||||||
// zone, or of the checks of clients with AbuseIPDB, which count tells.
|
// zone, or of the checks of clients with AbuseIPDB, which count tells.
|
||||||
func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) {
|
func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() int) {
|
||||||
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "sww_reputation_queries_total",
|
Name: "smallwebwaf_reputation_queries_total",
|
||||||
Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.",
|
Help: "Queries to the DNSBL zone, or checks of clients with AbuseIPDB.",
|
||||||
ConstLabels: source,
|
ConstLabels: source,
|
||||||
}, func() float64 {
|
}, func() float64 {
|
||||||
@@ -519,7 +519,7 @@ func (m *Metrics) addReputationQueries(source prometheus.Labels, count func() in
|
|||||||
// that failed, which count tells.
|
// that failed, which count tells.
|
||||||
func (m *Metrics) addReputationFailures(source prometheus.Labels, count func() int) {
|
func (m *Metrics) addReputationFailures(source prometheus.Labels, count func() int) {
|
||||||
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
m.registry.MustRegister(prometheus.NewCounterFunc(prometheus.CounterOpts{
|
||||||
Name: "sww_reputation_failures_total",
|
Name: "smallwebwaf_reputation_failures_total",
|
||||||
Help: "Fetches of the list, queries to the DNSBL zone, or checks with " +
|
Help: "Fetches of the list, queries to the DNSBL zone, or checks with " +
|
||||||
"AbuseIPDB, that failed.",
|
"AbuseIPDB, that failed.",
|
||||||
ConstLabels: source,
|
ConstLabels: source,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func TestHistoryKeepsEveryRequest(t *testing.T) {
|
|||||||
{Forwarded: true, Status: 502, ResponseBytes: 12},
|
{Forwarded: true, Status: 502, ResponseBytes: 12},
|
||||||
// Closed without an answer: refused, and no response.
|
// Closed without an answer: refused, and no response.
|
||||||
{Refused: true, Status: 0},
|
{Refused: true, Status: 0},
|
||||||
// Answered 404 for a path under /_sww/: neither forwarded nor
|
// Answered 404 for a path under /_smallwebwaf/: neither forwarded nor
|
||||||
// refused.
|
// refused.
|
||||||
{Status: 404},
|
{Status: 404},
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// and bytes counted over a minute, an hour and a day, as the "Counting
|
// and bytes counted over a minute, an hour and a day, as the "Counting
|
||||||
// method" section of SPEC.md describes, which tell when a request takes
|
// method" section of SPEC.md describes, which tell when a request takes
|
||||||
// the client over a rate limit or a byte limit, and each client's history
|
// the client over a rate limit or a byte limit, and each client's history
|
||||||
// since it was first seen. At most SWW_MAX_TRACKED_CLIENTS clients are
|
// since it was first seen. At most SWWAF_MAX_TRACKED_CLIENTS clients are
|
||||||
// kept, in memory, and written to clients.json and read from it by the
|
// kept, in memory, and written to clients.json and read from it by the
|
||||||
// state package.
|
// state package.
|
||||||
package ratelimit
|
package ratelimit
|
||||||
@@ -25,7 +25,7 @@ const (
|
|||||||
KindRequests = "requests"
|
KindRequests = "requests"
|
||||||
// KindBytes is a byte limit, on a client's bytes.
|
// KindBytes is a byte limit, on a client's bytes.
|
||||||
KindBytes = "bytes"
|
KindBytes = "bytes"
|
||||||
// KindRefusals is the error burst, on a client's requests sww
|
// KindRefusals is the error burst, on a client's requests smallwebwaf
|
||||||
// refused after a rule file match or for a missing or wrong token.
|
// refused after a rule file match or for a missing or wrong token.
|
||||||
KindRefusals = "refusals"
|
KindRefusals = "refusals"
|
||||||
)
|
)
|
||||||
@@ -92,9 +92,9 @@ type History struct {
|
|||||||
ASName string `json:"as_name,omitempty"`
|
ASName string `json:"as_name,omitempty"`
|
||||||
Country string `json:"country,omitempty"`
|
Country string `json:"country,omitempty"`
|
||||||
LookedUp time.Time `json:"looked_up,omitzero"`
|
LookedUp time.Time `json:"looked_up,omitzero"`
|
||||||
// Requests are all the client's requests: Forwarded those discriminator allowed,
|
// Requests are all the client's requests: Forwarded those servewaf allowed,
|
||||||
// Refused those it refused, and neither those for a path under
|
// Refused those it refused, and neither those for a path under
|
||||||
// /_sww/.
|
// /_smallwebwaf/.
|
||||||
Requests int64 `json:"requests"`
|
Requests int64 `json:"requests"`
|
||||||
Forwarded int64 `json:"forwarded"`
|
Forwarded int64 `json:"forwarded"`
|
||||||
Refused int64 `json:"refused"`
|
Refused int64 `json:"refused"`
|
||||||
@@ -122,12 +122,12 @@ type Responses struct {
|
|||||||
type Offences struct {
|
type Offences struct {
|
||||||
// Limit is its requests that broke a rate limit, a byte limit or the
|
// Limit is its requests that broke a rate limit, a byte limit or the
|
||||||
// error burst, Attack those that were a clear sign of attack, a match
|
// error burst, Attack those that were a clear sign of attack, a match
|
||||||
// of a ban rule or a request for a trap path, BlockedByBlockRule those a
|
// of a ban rule or a request for a trap path, RuleBlocked those a block
|
||||||
// block rule refused, and WAFBlocked those the Core Rule Set refused.
|
// rule refused, and WAFBlocked those the Core Rule Set refused.
|
||||||
Limit int64 `json:"limit"`
|
Limit int64 `json:"limit"`
|
||||||
Attack int64 `json:"attack"`
|
Attack int64 `json:"attack"`
|
||||||
BlockedByBlockRule int64 `json:"blocked_by_block_rule"`
|
RuleBlocked int64 `json:"rule_blocked"`
|
||||||
WAFBlocked int64 `json:"waf_blocked"`
|
WAFBlocked int64 `json:"waf_blocked"`
|
||||||
// TokenRefused is what a clients.json written by an earlier version gives,
|
// TokenRefused is what a clients.json written by an earlier version gives,
|
||||||
// which must still load. Nothing adds to it.
|
// which must still load. Nothing adds to it.
|
||||||
TokenRefused int64 `json:"token_refused,omitempty"`
|
TokenRefused int64 `json:"token_refused,omitempty"`
|
||||||
@@ -135,8 +135,8 @@ type Offences struct {
|
|||||||
|
|
||||||
// Request is what a client's history keeps of one of its requests.
|
// Request is what a client's history keeps of one of its requests.
|
||||||
type Request struct {
|
type Request struct {
|
||||||
// Forwarded is true for a request discriminator allowed, and Refused for one it
|
// Forwarded is true for a request servewaf allowed, and Refused for one it
|
||||||
// refused. Both are false for a request for a path under /_sww/.
|
// refused. Both are false for a request for a path under /_smallwebwaf/.
|
||||||
Forwarded bool
|
Forwarded bool
|
||||||
Refused bool
|
Refused bool
|
||||||
// Status is what the client was sent, 0 if nothing was.
|
// Status is what the client was sent, 0 if nothing was.
|
||||||
@@ -147,16 +147,16 @@ type Request struct {
|
|||||||
ResponseBytes int64
|
ResponseBytes int64
|
||||||
// BrokeLimit is true for a request that broke a rate limit, a byte
|
// BrokeLimit is true for a request that broke a rate limit, a byte
|
||||||
// limit or the error burst, Attack for one that matched a ban rule or
|
// limit or the error burst, Attack for one that matched a ban rule or
|
||||||
// asked for a trap path, BlockedByBlockRule for one a block rule refused,
|
// asked for a trap path, RuleBlocked for one a block rule refused, and
|
||||||
// and WAFBlocked for one the Core Rule Set refused.
|
// WAFBlocked for one the Core Rule Set refused.
|
||||||
BrokeLimit bool
|
BrokeLimit bool
|
||||||
Attack bool
|
Attack bool
|
||||||
BlockedByBlockRule bool
|
RuleBlocked bool
|
||||||
WAFBlocked bool
|
WAFBlocked bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a Limiter for limits, with no client counted yet, whose
|
// New returns a Limiter for limits, with no client counted yet, whose
|
||||||
// table holds at most maxClients clients (SWW_MAX_TRACKED_CLIENTS). Past
|
// table holds at most maxClients clients (SWWAF_MAX_TRACKED_CLIENTS). Past
|
||||||
// it, the least recently seen client is dropped, with its history, and
|
// it, the least recently seen client is dropped, with its history, and
|
||||||
// starts afresh if it comes back.
|
// starts afresh if it comes back.
|
||||||
func New(limits Limits, maxClients int) *Limiter {
|
func New(limits Limits, maxClients int) *Limiter {
|
||||||
@@ -258,7 +258,7 @@ func (l *Limiter) CountBytes(
|
|||||||
return l.count(client, now, 0, bytes, percent)
|
return l.count(client, now, 0, bytes, percent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountRefusal counts a request from client at now that sww
|
// CountRefusal counts a request from client at now that smallwebwaf
|
||||||
// refused after a rule file or Core Rule Set match or for a missing or
|
// refused after a rule file or Core Rule Set match or for a missing or
|
||||||
// wrong token, and reports whether the client's refusals in the minute
|
// wrong token, and reports whether the client's refusals in the minute
|
||||||
// that ends at now, this one included, are more than threshold, which
|
// that ends at now, this one included, are more than threshold, which
|
||||||
@@ -323,8 +323,8 @@ func (l *Limiter) AddToHistory(client netip.Prefix, now time.Time, r Request) {
|
|||||||
h.Offences.Attack++
|
h.Offences.Attack++
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.BlockedByBlockRule {
|
if r.RuleBlocked {
|
||||||
h.Offences.BlockedByBlockRule++
|
h.Offences.RuleBlocked++
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.WAFBlocked {
|
if r.WAFBlocked {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
const limit = 3
|
const limit = 3
|
||||||
|
|
||||||
// tableSize is the most clients the tests' tables hold, the default of
|
// tableSize is the most clients the tests' tables hold, the default of
|
||||||
// SWW_MAX_TRACKED_CLIENTS.
|
// SWWAF_MAX_TRACKED_CLIENTS.
|
||||||
const tableSize = 20000
|
const tableSize = 20000
|
||||||
|
|
||||||
// whole is the percentage of each limit a client gets when nothing lowers
|
// whole is the percentage of each limit a client gets when nothing lowers
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package remotelog sends the lines sww writes on stdout to the
|
// Package remotelog sends the lines smallwebwaf writes on stdout to the
|
||||||
// remote log endpoint, SWW_LOG_REMOTE_URL, as the "Request log" section
|
// remote log endpoint, SWWAF_LOG_REMOTE_URL, as the "Request log" section
|
||||||
// of SPEC.md describes: each line as the message of an RFC 5424 syslog
|
// of SPEC.md describes: each line as the message of an RFC 5424 syslog
|
||||||
// record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow
|
// record, over UDP, TCP or TLS. Lines wait in a bounded buffer, so a slow
|
||||||
// or unreachable endpoint never holds up a request or stdout.
|
// or unreachable endpoint never holds up a request or stdout.
|
||||||
@@ -24,7 +24,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The forms of SWW_LOG_REMOTE_URL, by its scheme.
|
// The forms of SWWAF_LOG_REMOTE_URL, by its scheme.
|
||||||
const (
|
const (
|
||||||
SchemeUDP = "syslog+udp"
|
SchemeUDP = "syslog+udp"
|
||||||
SchemeTCP = "syslog+tcp"
|
SchemeTCP = "syslog+tcp"
|
||||||
@@ -56,18 +56,18 @@ const (
|
|||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// URL is the endpoint (SWW_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or
|
// URL is the endpoint (SWWAF_LOG_REMOTE_URL): SchemeUDP, SchemeTCP or
|
||||||
// SchemeTLS, a host and a port.
|
// SchemeTLS, a host and a port.
|
||||||
URL *url.URL
|
URL *url.URL
|
||||||
// RootCAs are the certificates a SchemeTLS endpoint's certificate
|
// RootCAs are the certificates a SchemeTLS endpoint's certificate
|
||||||
// must chain to (SWW_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
|
// must chain to (SWWAF_LOG_REMOTE_TLS_CA_FILE), nil for the host's.
|
||||||
RootCAs *x509.CertPool
|
RootCAs *x509.CertPool
|
||||||
// Buffer is the most lines held while they wait to be sent
|
// Buffer is the most lines held while they wait to be sent
|
||||||
// (SWW_LOG_REMOTE_BUFFER).
|
// (SWWAF_LOG_REMOTE_BUFFER).
|
||||||
Buffer int
|
Buffer int
|
||||||
// Facility is the number of the records' syslog facility
|
// Facility is the number of the records' syslog facility
|
||||||
// (SWW_LOG_REMOTE_FACILITY), and AppName their APP-NAME
|
// (SWWAF_LOG_REMOTE_FACILITY), and AppName their APP-NAME
|
||||||
// (SWW_LOG_REMOTE_APP_NAME).
|
// (SWWAF_LOG_REMOTE_APP_NAME).
|
||||||
Facility int
|
Facility int
|
||||||
AppName string
|
AppName string
|
||||||
}
|
}
|
||||||
@@ -243,7 +243,7 @@ func (s *Sender) send(ctx context.Context, processLog *slog.Logger) net.Conn {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
processLog.Warn("sending to SWW_LOG_REMOTE_URL failed",
|
processLog.Warn("sending to SWWAF_LOG_REMOTE_URL failed",
|
||||||
"error", err.Error(), "connecting_again_in", delay.String())
|
"error", err.Error(), "connecting_again_in", delay.String())
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -586,7 +586,7 @@ func wantRetries(t *testing.T, logged *output, delays ...string) {
|
|||||||
var fields map[string]any
|
var fields map[string]any
|
||||||
|
|
||||||
err := json.Unmarshal([]byte(line), &fields)
|
err := json.Unmarshal([]byte(line), &fields)
|
||||||
if err != nil || fields["msg"] != "sending to SWW_LOG_REMOTE_URL failed" {
|
if err != nil || fields["msg"] != "sending to SWWAF_LOG_REMOTE_URL failed" {
|
||||||
t.Fatalf("logged %q", line)
|
t.Fatalf("logged %q", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,7 +612,7 @@ func testCertificate(t *testing.T) (tls.Certificate, *x509.CertPool) {
|
|||||||
|
|
||||||
template := &x509.Certificate{
|
template := &x509.Certificate{
|
||||||
SerialNumber: big.NewInt(1),
|
SerialNumber: big.NewInt(1),
|
||||||
Subject: pkix.Name{CommonName: "sww test CA"},
|
Subject: pkix.Name{CommonName: "smallwebwaf test CA"},
|
||||||
NotBefore: time.Date(1999, 12, 31, 0, 0, 0, 0, time.UTC),
|
NotBefore: time.Date(1999, 12, 31, 0, 0, 0, 0, time.UTC),
|
||||||
NotAfter: time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC),
|
NotAfter: time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||||
IsCA: true,
|
IsCA: true,
|
||||||
|
|||||||
@@ -58,17 +58,17 @@ type Checks struct {
|
|||||||
// AbuseIPDBParams are what NewAbuseIPDB needs.
|
// AbuseIPDBParams are what NewAbuseIPDB needs.
|
||||||
type AbuseIPDBParams struct {
|
type AbuseIPDBParams struct {
|
||||||
// URL is where clients are checked, normally AbuseIPDBURL, with Key,
|
// URL is where clients are checked, normally AbuseIPDBURL, with Key,
|
||||||
// the account's key (SWW_ABUSEIPDB_KEY).
|
// the account's key (SWWAF_ABUSEIPDB_KEY).
|
||||||
URL string
|
URL string
|
||||||
Key string
|
Key string
|
||||||
// MinScore is the least score that is a hit (SWW_ABUSEIPDB_MIN_SCORE),
|
// MinScore is the least score that is a hit (SWWAF_ABUSEIPDB_MIN_SCORE),
|
||||||
// and DailyBudget the most checks made in a day, in UTC
|
// and DailyBudget the most checks made in a day, in UTC
|
||||||
// (SWW_ABUSEIPDB_DAILY_BUDGET).
|
// (SWWAF_ABUSEIPDB_DAILY_BUDGET).
|
||||||
MinScore int64
|
MinScore int64
|
||||||
DailyBudget int
|
DailyBudget int
|
||||||
// CacheTTL is how long a score is used after it was fetched
|
// CacheTTL is how long a score is used after it was fetched
|
||||||
// (SWW_REPUTATION_CACHE_TTL), and Timeout how long a check may take
|
// (SWWAF_REPUTATION_CACHE_TTL), and Timeout how long a check may take
|
||||||
// (SWW_REPUTATION_TIMEOUT).
|
// (SWWAF_REPUTATION_TIMEOUT).
|
||||||
CacheTTL time.Duration
|
CacheTTL time.Duration
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Now tells the time, normally time.Now in UTC.
|
// Now tells the time, normally time.Now in UTC.
|
||||||
|
|||||||
@@ -397,7 +397,7 @@ func TestMetricsCountTheChecksTheFailuresAndTheBudgetLeft(t *testing.T) {
|
|||||||
"failures_total": "1",
|
"failures_total": "1",
|
||||||
"daily_budget_remaining": "3",
|
"daily_budget_remaining": "3",
|
||||||
} {
|
} {
|
||||||
line := "\nsww_reputation_" + series +
|
line := "\nsmallwebwaf_reputation_" + series +
|
||||||
`{instance="app",source="abuseipdb"} ` + want + "\n"
|
`{instance="app",source="abuseipdb"} ` + want + "\n"
|
||||||
if !strings.Contains(scraped, line) {
|
if !strings.Contains(scraped, line) {
|
||||||
t.Errorf("metrics\n%s\nwant%s", scraped, line)
|
t.Errorf("metrics\n%s\nwant%s", scraped, line)
|
||||||
@@ -581,7 +581,7 @@ func wantScore(
|
|||||||
|
|
||||||
// hitFrom is checker's Hit for a request from address, offender or not.
|
// hitFrom is checker's Hit for a request from address, offender or not.
|
||||||
// Its client is address for an IPv4 address, and its /64 for an IPv6 one,
|
// Its client is address for an IPv4 address, and its /64 for an IPv6 one,
|
||||||
// as sww counts clients.
|
// as smallwebwaf counts clients.
|
||||||
func hitFrom(
|
func hitFrom(
|
||||||
t *testing.T, checker *reputation.AbuseIPDB, address string, offender bool,
|
t *testing.T, checker *reputation.AbuseIPDB, address string, offender bool,
|
||||||
) (int64, bool) {
|
) (int64, bool) {
|
||||||
|
|||||||
@@ -53,15 +53,15 @@ type Verdict struct {
|
|||||||
// DNSBLParams are what NewDNSBL needs.
|
// DNSBLParams are what NewDNSBL needs.
|
||||||
type DNSBLParams struct {
|
type DNSBLParams struct {
|
||||||
// Zones are the DNSBL zones clients are asked about in
|
// Zones are the DNSBL zones clients are asked about in
|
||||||
// (SWW_DNSBL_ZONES).
|
// (SWWAF_DNSBL_ZONES).
|
||||||
Zones []string
|
Zones []string
|
||||||
// Resolver is the resolver they are asked through
|
// Resolver is the resolver they are asked through
|
||||||
// (SWW_DNSBL_RESOLVER), or, while it is the zero AddrPort, the
|
// (SWWAF_DNSBL_RESOLVER), or, while it is the zero AddrPort, the
|
||||||
// host's, as /etc/resolv.conf names it.
|
// host's, as /etc/resolv.conf names it.
|
||||||
Resolver netip.AddrPort
|
Resolver netip.AddrPort
|
||||||
// CacheTTL is how long a verdict is used after it was fetched
|
// CacheTTL is how long a verdict is used after it was fetched
|
||||||
// (SWW_REPUTATION_CACHE_TTL), and Timeout how long a query may take
|
// (SWWAF_REPUTATION_CACHE_TTL), and Timeout how long a query may take
|
||||||
// (SWW_REPUTATION_TIMEOUT).
|
// (SWWAF_REPUTATION_TIMEOUT).
|
||||||
CacheTTL time.Duration
|
CacheTTL time.Duration
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Now tells the time, normally time.Now in UTC.
|
// Now tells the time, normally time.Now in UTC.
|
||||||
@@ -127,13 +127,13 @@ func NewDNSBL(params DNSBLParams) *DNSBL {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zones returns the zones, in the order SWW_DNSBL_ZONES names them.
|
// Zones returns the zones, in the order SWWAF_DNSBL_ZONES names them.
|
||||||
func (d *DNSBL) Zones() []string {
|
func (d *DNSBL) Zones() []string {
|
||||||
return slices.Clone(d.params.Zones)
|
return slices.Clone(d.params.Zones)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListedBy returns the zones whose verdict on addr, a client's address,
|
// ListedBy returns the zones whose verdict on addr, a client's address,
|
||||||
// lists it, in the order SWW_DNSBL_ZONES names them, each with its key
|
// lists it, in the order SWWAF_DNSBL_ZONES names them, each with its key
|
||||||
// masked, as config.MaskZoneKey masks it, since they go to the request
|
// masked, as config.MaskZoneKey masks it, since they go to the request
|
||||||
// log, the alerts and the metrics. A verdict is used until CacheTTL has
|
// log, the alerts and the metrics. A verdict is used until CacheTTL has
|
||||||
// passed since it was fetched. Each zone without one is asked about addr
|
// passed since it was fetched. Each zone without one is asked about addr
|
||||||
@@ -210,7 +210,7 @@ func (d *DNSBL) Snapshot() []Verdict {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load keeps verdicts, read from reputation.json, in place of those it
|
// Load keeps verdicts, read from reputation.json, in place of those it
|
||||||
// keeps, but for those of a zone SWW_DNSBL_ZONES does not name, and,
|
// keeps, but for those of a zone SWWAF_DNSBL_ZONES does not name, and,
|
||||||
// past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or
|
// past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or
|
||||||
// more is neither used nor written, as for any verdict.
|
// more is neither used nor written, as for any verdict.
|
||||||
func (d *DNSBL) Load(verdicts []Verdict) {
|
func (d *DNSBL) Load(verdicts []Verdict) {
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ const (
|
|||||||
// zone and otherZone are the DNSBL zones the tests name.
|
// zone and otherZone are the DNSBL zones the tests name.
|
||||||
zone = "dnsbl.example"
|
zone = "dnsbl.example"
|
||||||
otherZone = "other.example"
|
otherZone = "other.example"
|
||||||
// cacheTTL is the tests' SWW_REPUTATION_CACHE_TTL, and timeout their
|
// cacheTTL is the tests' SWWAF_REPUTATION_CACHE_TTL, and timeout their
|
||||||
// SWW_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
|
// SWWAF_REPUTATION_TIMEOUT: a second, the least time /etc/resolv.conf
|
||||||
// can have Go's resolver wait for one server, so that it is the
|
// can have Go's resolver wait for one server, so that it is the
|
||||||
// DNSBL's own timeout that ends a query, whatever that file says.
|
// DNSBL's own timeout that ends a query, whatever that file says.
|
||||||
cacheTTL = 24 * time.Hour
|
cacheTTL = 24 * time.Hour
|
||||||
@@ -311,7 +311,7 @@ func TestMetricsCountEachZonesQueriesAndThoseThatFailed(t *testing.T) {
|
|||||||
"queries_total" + `{instance="app",source="` + otherZone + `"}`: "1",
|
"queries_total" + `{instance="app",source="` + otherZone + `"}`: "1",
|
||||||
"failures_total" + `{instance="app",source="` + otherZone + `"}`: "1",
|
"failures_total" + `{instance="app",source="` + otherZone + `"}`: "1",
|
||||||
} {
|
} {
|
||||||
line := "\nsww_reputation_" + series + " " + want + "\n"
|
line := "\nsmallwebwaf_reputation_" + series + " " + want + "\n"
|
||||||
if !strings.Contains(scraped.Body.String(), line) {
|
if !strings.Contains(scraped.Body.String(), line) {
|
||||||
t.Errorf("metrics\n%s\nwant%s", scraped.Body.String(), line)
|
t.Errorf("metrics\n%s\nwant%s", scraped.Body.String(), line)
|
||||||
}
|
}
|
||||||
@@ -419,7 +419,7 @@ func TestNeitherAVerdictOfAZoneNotNamedNorOnePastItsTTLIsKept(t *testing.T) {
|
|||||||
dnsbl := reputation.NewDNSBL(p)
|
dnsbl := reputation.NewDNSBL(p)
|
||||||
|
|
||||||
// The last verdict still in use, one fetched a TTL ago, and one of a
|
// The last verdict still in use, one fetched a TTL ago, and one of a
|
||||||
// zone SWW_DNSBL_ZONES does not name.
|
// zone SWWAF_DNSBL_ZONES does not name.
|
||||||
inUse := reputation.Verdict{
|
inUse := reputation.Verdict{
|
||||||
Zone: zone, Client: netip.MustParseAddr(listed), Listed: true,
|
Zone: zone, Client: netip.MustParseAddr(listed), Listed: true,
|
||||||
Fetched: now.Add(-cacheTTL + time.Nanosecond),
|
Fetched: now.Add(-cacheTTL + time.Nanosecond),
|
||||||
@@ -471,7 +471,7 @@ func TestAtMost100000VerdictsKeptTheOneFetchedLongestAgoDroppedFirst(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // one at a time, as the comment at the top of this file says
|
//nolint:paralleltest // one at a time, as the comment at the top of this file says
|
||||||
func TestQueriesGoToTheResolverSWWDNSBLResolverNames(t *testing.T) {
|
func TestQueriesGoToTheResolverSWWAFDNSBLResolverNames(t *testing.T) {
|
||||||
resolver := &resolverStandIn{answers: map[string]answer{
|
resolver := &resolverStandIn{answers: map[string]answer{
|
||||||
listedName: {addrs: []string{listing}},
|
listedName: {addrs: []string{listing}},
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
// Package reputation fetches the lists the settings name by URL: the
|
// Package reputation fetches the lists the settings name by URL: the
|
||||||
// blocklists of SWW_BLOCKLIST_URLS, the file of AS:percent lines
|
// blocklists of SWWAF_BLOCKLIST_URLS, the file of AS:percent lines
|
||||||
// SWW_ASN_LIMIT_PERCENT_URL names, the address lists of SWW_TAG_LISTS, and
|
// SWWAF_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec
|
||||||
// the decision list of the CrowdSec engine SWW_CROWDSEC_LAPI_URL names. It
|
// engine SWWAF_CROWDSEC_LAPI_URL names. It keeps the last good copy of
|
||||||
// keeps the last good copy of
|
|
||||||
// each, whole, comment lines included, which is used while a fetch fails,
|
// each, whole, comment lines included, which is used while a fetch fails,
|
||||||
// and when each was last tried. It also asks the DNSBL zones of
|
// and when each was last tried. It also asks the DNSBL zones of
|
||||||
// SWW_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
// SWWAF_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
||||||
// clients with AbuseIPDB, and keeps their scores and the checks spent
|
// clients with AbuseIPDB, and keeps their scores and the checks spent
|
||||||
// today. The state package writes all of these to reputation.json and
|
// today. The state package writes all of these to reputation.json and
|
||||||
// reads them from it, so that a restart keeps them too.
|
// reads them from it, so that a restart keeps them too.
|
||||||
@@ -67,20 +66,18 @@ type List struct {
|
|||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// BlocklistURLs are the blocklists (SWW_BLOCKLIST_URLS), and
|
// BlocklistURLs are the blocklists (SWWAF_BLOCKLIST_URLS), and
|
||||||
// ASNLimitPercentURL the file of AS:percent lines
|
// ASNLimitPercentURL the file of AS:percent lines
|
||||||
// (SWW_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
// (SWWAF_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
||||||
BlocklistURLs []string
|
BlocklistURLs []string
|
||||||
ASNLimitPercentURL string
|
ASNLimitPercentURL string
|
||||||
// TagLists are the address lists that add a tag (SWW_TAG_LISTS).
|
|
||||||
TagLists []config.TagList
|
|
||||||
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
|
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
|
||||||
// SWW_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
// SWWAF_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
||||||
// (SWW_CROWDSEC_LAPI_KEY).
|
// (SWWAF_CROWDSEC_LAPI_KEY).
|
||||||
CrowdSecDecisionsURL string
|
CrowdSecDecisionsURL string
|
||||||
CrowdSecKey string
|
CrowdSecKey string
|
||||||
// Refresh is how long after a list was last fetched or tried it is
|
// Refresh is how long after a list was last fetched or tried it is
|
||||||
// fetched again (SWW_BLOCKLIST_REFRESH), but for the CrowdSec decision
|
// fetched again (SWWAF_BLOCKLIST_REFRESH), but for the CrowdSec decision
|
||||||
// list, which is fetched again crowdSecRefresh after.
|
// list, which is fetched again crowdSecRefresh after.
|
||||||
Refresh time.Duration
|
Refresh time.Duration
|
||||||
// Now tells the time, normally time.Now in UTC.
|
// Now tells the time, normally time.Now in UTC.
|
||||||
@@ -115,11 +112,11 @@ type list struct {
|
|||||||
failures int
|
failures int
|
||||||
}
|
}
|
||||||
|
|
||||||
// entries are what the lines of a copy say: for a blocklist or a tag list, the
|
// entries are what the lines of a copy say: for a blocklist, the netblocks
|
||||||
// netblocks it names, with the lengths among them, for the file of AS:percent
|
// it names, with the lengths among them, for the file of AS:percent lines,
|
||||||
// lines, the percentage it gives each AS number, and for the CrowdSec decision
|
// the percentage it gives each AS number, and for the CrowdSec decision
|
||||||
// list, the decision on each netblock that ends last, with the lengths among
|
// list, the decision on each netblock that ends last, with the lengths
|
||||||
// them.
|
// among them.
|
||||||
type entries struct {
|
type entries struct {
|
||||||
netblocks map[netip.Prefix]bool
|
netblocks map[netip.Prefix]bool
|
||||||
lengths []int
|
lengths []int
|
||||||
@@ -155,18 +152,14 @@ func New(params Params) *Lists {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// URLs returns the URL of every list: the blocklists' in the order
|
// URLs returns the URL of every list: the blocklists' in the order
|
||||||
// SWW_BLOCKLIST_URLS names them, then SWW_ASN_LIMIT_PERCENT_URL, then the
|
// SWWAF_BLOCKLIST_URLS names them, then SWWAF_ASN_LIMIT_PERCENT_URL, then
|
||||||
// tag lists', then the CrowdSec decision list's.
|
// the CrowdSec decision list's.
|
||||||
func (l *Lists) URLs() []string {
|
func (l *Lists) URLs() []string {
|
||||||
urls := slices.Clone(l.params.BlocklistURLs)
|
urls := slices.Clone(l.params.BlocklistURLs)
|
||||||
if l.params.ASNLimitPercentURL != "" {
|
if l.params.ASNLimitPercentURL != "" {
|
||||||
urls = append(urls, l.params.ASNLimitPercentURL)
|
urls = append(urls, l.params.ASNLimitPercentURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, list := range l.params.TagLists {
|
|
||||||
urls = append(urls, list.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if l.params.CrowdSecDecisionsURL != "" {
|
if l.params.CrowdSecDecisionsURL != "" {
|
||||||
urls = append(urls, l.params.CrowdSecDecisionsURL)
|
urls = append(urls, l.params.CrowdSecDecisionsURL)
|
||||||
}
|
}
|
||||||
@@ -175,7 +168,7 @@ func (l *Lists) URLs() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListedBy returns the URLs of the blocklists whose copy lists addr, in
|
// ListedBy returns the URLs of the blocklists whose copy lists addr, in
|
||||||
// the order SWW_BLOCKLIST_URLS names them.
|
// the order SWWAF_BLOCKLIST_URLS names them.
|
||||||
func (l *Lists) ListedBy(addr netip.Addr) []string {
|
func (l *Lists) ListedBy(addr netip.Addr) []string {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
@@ -191,22 +184,6 @@ func (l *Lists) ListedBy(addr netip.Addr) []string {
|
|||||||
return listedBy
|
return listedBy
|
||||||
}
|
}
|
||||||
|
|
||||||
// TagsOf returns the names of the tag lists whose copy holds addr.
|
|
||||||
func (l *Lists) TagsOf(addr netip.Addr) []string {
|
|
||||||
l.mu.Lock()
|
|
||||||
defer l.mu.Unlock()
|
|
||||||
|
|
||||||
var names []string
|
|
||||||
|
|
||||||
for _, list := range l.params.TagLists {
|
|
||||||
if l.lists[list.URL].entries.contain(addr) {
|
|
||||||
names = append(names, list.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
// ASNLimitPercent returns the percentage the copy of the file of
|
// ASNLimitPercent returns the percentage the copy of the file of
|
||||||
// AS:percent lines gives asn, and whether it lists asn.
|
// AS:percent lines gives asn, and whether it lists asn.
|
||||||
func (l *Lists) ASNLimitPercent(asn string) (int64, bool) {
|
func (l *Lists) ASNLimitPercent(asn string) (int64, bool) {
|
||||||
@@ -394,7 +371,7 @@ func (l *Lists) due(listURL string) time.Time {
|
|||||||
// fetch fetches the list at listURL, and notes the try. A good copy takes
|
// fetch fetches the list at listURL, and notes the try. A good copy takes
|
||||||
// the place of the one held. A failure leaves that in use, and is counted,
|
// the place of the one held. A failure leaves that in use, and is counted,
|
||||||
// logged and raised as a source_failure alert. A fetch cut off as ctx
|
// logged and raised as a source_failure alert. A fetch cut off as ctx
|
||||||
// ends, as sww stops, is no failure, but is still noted as a try,
|
// ends, as smallwebwaf stops, is no failure, but is still noted as a try,
|
||||||
// so that a restart waits for it: the server may have had its request.
|
// so that a restart waits for it: the server may have had its request.
|
||||||
func (l *Lists) fetch(ctx context.Context, listURL string) {
|
func (l *Lists) fetch(ctx context.Context, listURL string) {
|
||||||
lines, err := l.get(ctx, listURL)
|
lines, err := l.get(ctx, listURL)
|
||||||
@@ -504,10 +481,10 @@ func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parse reads the lines of the list at listURL, fetched at fetched: those
|
// parse reads the lines of the list at listURL, fetched at fetched: those
|
||||||
// of a blocklist or a tag list, of the file of AS:percent lines, or of the
|
// of a blocklist, of the file of AS:percent lines, or of the CrowdSec
|
||||||
// CrowdSec decision list. In all but the decision list, anything after a ; or
|
// decision list. In the first two, anything after a ; or a # on a line is
|
||||||
// a # on a line is left out, and so is a line left blank. Any other line that
|
// left out, and so is a line left blank. Any other line that does not read
|
||||||
// does not read is an error naming it by its number.
|
// is an error naming it by its number.
|
||||||
func (l *Lists) parse(
|
func (l *Lists) parse(
|
||||||
listURL string, lines []string, fetched time.Time,
|
listURL string, lines []string, fetched time.Time,
|
||||||
) (entries, error) {
|
) (entries, error) {
|
||||||
@@ -521,8 +498,8 @@ func (l *Lists) parse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseNetblocks reads the lines of a blocklist or a tag list, each an address
|
// parseNetblocks reads a blocklist's lines, each an address or a netblock
|
||||||
// or a netblock as the settings take them.
|
// as the settings take them.
|
||||||
func parseNetblocks(lines []string) (entries, error) {
|
func parseNetblocks(lines []string) (entries, error) {
|
||||||
found := entries{netblocks: map[netip.Prefix]bool{}}
|
found := entries{netblocks: map[netip.Prefix]bool{}}
|
||||||
|
|
||||||
@@ -574,7 +551,7 @@ func parseNetblock(text string) (netip.Prefix, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parsePercents reads the lines of the file of AS:percent lines, each an
|
// parsePercents reads the lines of the file of AS:percent lines, each an
|
||||||
// AS number, : and a percentage, such as AS64496:50. An
|
// AS number, : and a percentage, as SWWAF_ASN_LIMIT_PERCENT takes them. An
|
||||||
// AS number listed more than once gets the lowest of its percentages.
|
// AS number listed more than once gets the lowest of its percentages.
|
||||||
func parsePercents(lines []string) (entries, error) {
|
func parsePercents(lines []string) (entries, error) {
|
||||||
found := entries{percents: map[string]int64{}}
|
found := entries{percents: map[string]int64{}}
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ const (
|
|||||||
dropURL = "https://lists.example/drop.txt"
|
dropURL = "https://lists.example/drop.txt"
|
||||||
torURL = "https://lists.example/tor.txt"
|
torURL = "https://lists.example/tor.txt"
|
||||||
asnURL = "https://lists.example/asn.txt"
|
asnURL = "https://lists.example/asn.txt"
|
||||||
// refresh is the tests' SWW_BLOCKLIST_REFRESH, and cooldown their
|
// refresh is the tests' SWWAF_BLOCKLIST_REFRESH, and cooldown their
|
||||||
// SWW_ALERT_COOLDOWN, longer than it.
|
// SWWAF_ALERT_COOLDOWN, longer than it.
|
||||||
refresh = 24 * time.Hour
|
refresh = 24 * time.Hour
|
||||||
cooldown = 48 * time.Hour
|
cooldown = 48 * time.Hour
|
||||||
// drop is a blocklist as the Spamhaus DROP list is written, with an
|
// drop is a blocklist as the Spamhaus DROP list is written, with an
|
||||||
@@ -116,7 +116,7 @@ func TestClientIsListedByEachBlocklistThatListsIt(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
lists := start(t, servers, params(torURL, dropURL))
|
lists := start(t, servers, params(torURL, dropURL))
|
||||||
|
|
||||||
// In the order SWW_BLOCKLIST_URLS names them.
|
// In the order SWWAF_BLOCKLIST_URLS names them.
|
||||||
wantListedBy(t, lists, "203.0.113.9", torURL, dropURL)
|
wantListedBy(t, lists, "203.0.113.9", torURL, dropURL)
|
||||||
wantListedBy(t, lists, "203.0.113.8", dropURL)
|
wantListedBy(t, lists, "203.0.113.8", dropURL)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Package requestlog writes the lines sww prints on stdout: one
|
// Package requestlog writes the lines smallwebwaf prints on stdout: one
|
||||||
// JSON object per request, two from discriminator and a short one from rproxy,
|
// JSON object per request, two from servewaf and a short one from rproxy, marked
|
||||||
// marked "type":"request", and the process's own messages as JSON lines marked
|
// "type":"request", and the process's own messages as JSON lines marked
|
||||||
// "type":"process".
|
// "type":"process".
|
||||||
package requestlog
|
package requestlog
|
||||||
|
|
||||||
@@ -14,49 +14,36 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The action a request line names: what discriminator did with the request.
|
// The action a request line names: what servewaf did with the request.
|
||||||
const (
|
const (
|
||||||
// ActionForward is a request discriminator allowed, which rproxy passes to
|
// ActionForward is a request servewaf allowed, which rproxy passes to the
|
||||||
// the app.
|
// app.
|
||||||
ActionForward = "forward"
|
ActionForward = "forward"
|
||||||
|
// ActionTooLarge is a request over the header limits.
|
||||||
// ActionRequestHeaderTooLarge is a request over the header limits.
|
ActionTooLarge = "too_large"
|
||||||
ActionRequestHeaderTooLarge = "request_header_too_large"
|
// ActionRateLimited is a request refused because it took its client
|
||||||
|
// over a rate limit, which bans the client.
|
||||||
// ActionClientIsRateLimited is a request refused because it took its
|
ActionRateLimited = "rate_limited"
|
||||||
// client over a rate limit, which bans the client.
|
// ActionBanned is a request refused because a ban covers its client,
|
||||||
ActionClientIsRateLimited = "client_is_rate_limited"
|
// or because it matched a ban rule, asked for a trap path or the
|
||||||
|
// CrowdSec decision list lists its client, each of which bans the
|
||||||
// ActionTemporarilyBanned is a request refused because a ban that runs
|
// client.
|
||||||
// out covers its client, or because it matched a ban rule, asked for a
|
ActionBanned = "banned"
|
||||||
// trap path or the CrowdSec decision list lists its client, and so made
|
// ActionRuleBlocked is a request refused because it matched a block
|
||||||
// such a ban.
|
// rule.
|
||||||
ActionTemporarilyBanned = "temporarily_banned"
|
ActionRuleBlocked = "rule_blocked"
|
||||||
|
|
||||||
// ActionPermanentlyBanned is a request refused because a ban that never
|
|
||||||
// runs out covers its client, or because the request made such a ban or
|
|
||||||
// made its client's ban permanent.
|
|
||||||
ActionPermanentlyBanned = "permanently_banned"
|
|
||||||
|
|
||||||
// ActionBlockedByBlockRule is a request refused because it matched a
|
|
||||||
// block rule.
|
|
||||||
ActionBlockedByBlockRule = "blocked_by_block_rule"
|
|
||||||
|
|
||||||
// ActionWAFBlocked is a request refused because the Core Rule Set
|
// ActionWAFBlocked is a request refused because the Core Rule Set
|
||||||
// scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
|
// scored it at or over SWWAF_WAF_ANOMALY_THRESHOLD.
|
||||||
ActionWAFBlocked = "waf_blocked"
|
ActionWAFBlocked = "waf_blocked"
|
||||||
|
// ActionDenied is a request refused because its client is in
|
||||||
// ActionClientDenied is a request refused because its client is in
|
// SWWAF_DENY_NETS, in a blocklist while SWWAF_BLOCKLIST_ACTION is deny,
|
||||||
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
|
|
||||||
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
||||||
// SWW_REPUTATION_ACTION is deny, or that carries a tag in SWW_TAG_DENY.
|
// SWWAF_REPUTATION_ACTION is deny.
|
||||||
ActionClientDenied = "client_denied"
|
ActionDenied = "denied"
|
||||||
|
// ActionCountryDenied is a request refused for its client's country.
|
||||||
// ActionCountryDenied is a request refused because its client's country
|
|
||||||
// is not in SWW_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
|
||||||
ActionCountryDenied = "country_denied"
|
ActionCountryDenied = "country_denied"
|
||||||
// ActionAdmin is a request discriminator answered at one of its own
|
// ActionAdmin is a request servewaf answered at one of its own endpoints,
|
||||||
// endpoints, or a question about a path under /_sww/.
|
// or a question about a path under /_smallwebwaf/.
|
||||||
ActionAdmin = "admin"
|
ActionAdmin = "admin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,14 +56,14 @@ const OffenceLimit = "limit"
|
|||||||
// that broke the error burst.
|
// that broke the error burst.
|
||||||
const LimitHitErrorBurst = "error_burst"
|
const LimitHitErrorBurst = "error_burst"
|
||||||
|
|
||||||
// The discriminator_line a request line names, which says which of
|
// The servewaf_line a request line names, which says which of servewaf's two
|
||||||
// discriminator's two lines about a question it is.
|
// lines about a question it is.
|
||||||
const (
|
const (
|
||||||
// DiscriminatorLineDecision is discriminator's line at its decision.
|
// ServeWAFLineDecision is servewaf's line at its decision.
|
||||||
DiscriminatorLineDecision = "decision"
|
ServeWAFLineDecision = "decision"
|
||||||
// DiscriminatorLineReport is discriminator's line when rproxy's report
|
// ServeWAFLineReport is servewaf's line when rproxy's report comes, with
|
||||||
// comes, with the decision's fields and the report's together.
|
// the decision's fields and the report's together.
|
||||||
DiscriminatorLineReport = "report"
|
ServeWAFLineReport = "report"
|
||||||
)
|
)
|
||||||
|
|
||||||
// timeLayout is RFC 3339 with milliseconds.
|
// timeLayout is RFC 3339 with milliseconds.
|
||||||
@@ -89,9 +76,9 @@ const timeLayout = "2006-01-02T15:04:05.000Z07:00"
|
|||||||
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
|
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
|
||||||
type Line struct {
|
type Line struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
// DiscriminatorLine is DiscriminatorLineDecision or DiscriminatorLineReport
|
// ServeWAFLine is ServeWAFLineDecision or ServeWAFLineReport on servewaf's
|
||||||
// on discriminator's two lines about a question.
|
// two lines about a question.
|
||||||
DiscriminatorLine string `json:"discriminator_line,omitempty"`
|
ServeWAFLine string `json:"servewaf_line,omitempty"`
|
||||||
|
|
||||||
// The standard web log fields. Scheme is how the client reached rproxy, or
|
// The standard web log fields. Scheme is how the client reached rproxy, or
|
||||||
// the trusted proxy in front of it.
|
// the trusted proxy in front of it.
|
||||||
@@ -111,7 +98,7 @@ type Line struct {
|
|||||||
UserAgent string `json:"user_agent"`
|
UserAgent string `json:"user_agent"`
|
||||||
|
|
||||||
// Request detail. RequestID is the id rproxy made for the request, or for a
|
// Request detail. RequestID is the id rproxy made for the request, or for a
|
||||||
// request to discriminator's own endpoints a new one. ForwardedFor is the
|
// request to servewaf's own endpoints a new one. ForwardedFor is the
|
||||||
// X-Forwarded-For header as received. ClientGroup is the netblock the
|
// X-Forwarded-For header as received. ClientGroup is the netblock the
|
||||||
// client is counted as. ASN, ASName and Country are the client's AS
|
// client is counted as. ASN, ASName and Country are the client's AS
|
||||||
// number, AS name and country, as looked up.
|
// number, AS name and country, as looked up.
|
||||||
@@ -125,35 +112,25 @@ type Line struct {
|
|||||||
ContentType string `json:"content_type,omitempty"`
|
ContentType string `json:"content_type,omitempty"`
|
||||||
// ContentLength is the length of its body the request announced.
|
// ContentLength is the length of its body the request announced.
|
||||||
ContentLength int64 `json:"content_length,omitempty"`
|
ContentLength int64 `json:"content_length,omitempty"`
|
||||||
// RequestHeaders are the headers SWW_LOG_REQUEST_HEADERS names that
|
// RequestHeaders are the headers SWWAF_LOG_REQUEST_HEADERS names that
|
||||||
// the request carried, by name in lower case.
|
// the request carried, by name in lower case.
|
||||||
RequestHeaders map[string]string `json:"request_headers,omitempty"`
|
RequestHeaders map[string]string `json:"request_headers,omitempty"`
|
||||||
HasAuthorization bool `json:"has_authorization,omitempty"`
|
HasAuthorization bool `json:"has_authorization,omitempty"`
|
||||||
HasCookie bool `json:"has_cookie,omitempty"`
|
HasCookie bool `json:"has_cookie,omitempty"`
|
||||||
|
|
||||||
// Response detail, of discriminator's own answer. Aborted is true when
|
// Response detail, of servewaf's own answer. Aborted is true when rproxy, or
|
||||||
// rproxy, or the admin's tool, went away before the answer.
|
// the admin's tool, went away before the answer.
|
||||||
ResponseContentType string `json:"response_content_type,omitempty"`
|
ResponseContentType string `json:"response_content_type,omitempty"`
|
||||||
Aborted bool `json:"aborted,omitempty"`
|
Aborted bool `json:"aborted,omitempty"`
|
||||||
|
|
||||||
// The decision.
|
// The decision.
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
// WouldAction is, in observe mode, the action enforce mode would have
|
// WouldAction is, in observe mode, the action enforce mode would have
|
||||||
// taken with a request it would have refused: ActionClientDenied,
|
// taken with a request it would have refused: ActionDenied,
|
||||||
// ActionTemporarilyBanned, ActionPermanentlyBanned, ActionCountryDenied,
|
// ActionBanned, ActionCountryDenied, ActionRateLimited,
|
||||||
// ActionClientIsRateLimited, ActionBlockedByBlockRule, ActionWAFBlocked,
|
// ActionRuleBlocked, ActionWAFBlocked, or, in servewaf, ActionTooLarge
|
||||||
// or, in discriminator, ActionRequestHeaderTooLarge for the header limits.
|
// 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
|
|
||||||
// observe mode too, and HeuristicFigures are the figures it acted on, by
|
|
||||||
// name.
|
|
||||||
Heuristic string `json:"heuristic,omitempty"`
|
|
||||||
HeuristicFigures map[string]any `json:"heuristic_figures,omitempty"`
|
|
||||||
// AfterAnswerHeuristic and AfterAnswerHeuristicFigures are the same for
|
|
||||||
// the heuristic that banned the client after discriminator answered the
|
|
||||||
// request.
|
|
||||||
AfterAnswerHeuristic string `json:"after_answer_heuristic,omitempty"`
|
|
||||||
AfterAnswerHeuristicFigures map[string]any `json:"after_answer_heuristic_figures,omitempty"` //nolint:lll // a struct tag cannot be split
|
|
||||||
// LimitPercent and LimitPercentSetting are, for a request the rate
|
// LimitPercent and LimitPercentSetting are, for a request the rate
|
||||||
// limits counted whose client a biased threshold gives a percentage of
|
// limits counted whose client a biased threshold gives a percentage of
|
||||||
// the rate limits below 100, that percentage and the setting that gave
|
// the rate limits below 100, that percentage and the setting that gave
|
||||||
@@ -185,12 +162,6 @@ type Line struct {
|
|||||||
// whose verdict lists it, their keys masked, then abuseipdb when its
|
// whose verdict lists it, their keys masked, then abuseipdb when its
|
||||||
// score is a hit.
|
// score is a hit.
|
||||||
Reputation []string `json:"reputation,omitempty"`
|
Reputation []string `json:"reputation,omitempty"`
|
||||||
// Tags are the request's tags, sorted.
|
|
||||||
Tags []string `json:"tags,omitempty"`
|
|
||||||
// RouteWatchTagsMissing, while SWW_ROUTEWATCH_URL is set, says why the
|
|
||||||
// request got no tags from routewatch: no_answer_yet, request_failed or
|
|
||||||
// no_route.
|
|
||||||
RouteWatchTagsMissing string `json:"routewatch_tags_missing,omitempty"`
|
|
||||||
// Offence is the offence the request was held as, OffenceLimit.
|
// Offence is the offence the request was held as, OffenceLimit.
|
||||||
Offence string `json:"offence,omitempty"`
|
Offence string `json:"offence,omitempty"`
|
||||||
// BanExpires is when the ban the request made, or was refused under,
|
// BanExpires is when the ban the request made, or was refused under,
|
||||||
@@ -230,7 +201,7 @@ type RProxyLine struct {
|
|||||||
// if that is sooner. DurationResponse runs from then until the answer ends.
|
// if that is sooner. DurationResponse runs from then until the answer ends.
|
||||||
DurationRequest float64 `json:"duration_request"`
|
DurationRequest float64 `json:"duration_request"`
|
||||||
DurationResponse float64 `json:"duration_response"`
|
DurationResponse float64 `json:"duration_response"`
|
||||||
// Disposition is the valid disposition discriminator answered or, while
|
// Disposition is the valid disposition servewaf answered or, while
|
||||||
// DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is ""
|
// DefaultDisposition is true, the value of SWRP_DEFAULT_DISPOSITION. It is ""
|
||||||
// when neither gave one.
|
// when neither gave one.
|
||||||
Disposition string `json:"disposition,omitempty"`
|
Disposition string `json:"disposition,omitempty"`
|
||||||
@@ -274,37 +245,25 @@ func FormatTime(t time.Time) string {
|
|||||||
|
|
||||||
// Milliseconds is d in milliseconds, to the microsecond.
|
// Milliseconds is d in milliseconds, to the microsecond.
|
||||||
func Milliseconds(d time.Duration) float64 {
|
func Milliseconds(d time.Duration) float64 {
|
||||||
return float64(
|
return float64(d.Microseconds()) / float64(time.Millisecond/time.Microsecond)
|
||||||
d.Microseconds(),
|
|
||||||
) / float64(
|
|
||||||
time.Millisecond/time.Microsecond,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProcessLogger returns the logger for the process's own messages:
|
// NewProcessLogger returns the logger for the process's own messages:
|
||||||
// JSON lines on w, marked "type":"process", with the time in the same form
|
// JSON lines on w, marked "type":"process", with the time in the same form
|
||||||
// as a request line's, and instanceName, SWW_INSTANCE_NAME, as instance.
|
// as a request line's, and instanceName, SWWAF_INSTANCE_NAME, as instance.
|
||||||
// It writes only the messages at level, SWW_LOG_LEVEL, or more severe;
|
// It writes only the messages at level, SWWAF_LOG_LEVEL, or more severe;
|
||||||
// the request lines Write writes are never held back.
|
// the request lines Write writes are never held back.
|
||||||
func NewProcessLogger(
|
func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger {
|
||||||
w io.Writer,
|
|
||||||
instanceName string,
|
|
||||||
level slog.Level,
|
|
||||||
) *slog.Logger {
|
|
||||||
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
||||||
Level: level,
|
Level: level,
|
||||||
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
|
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
|
||||||
if attr.Key == slog.TimeKey && len(groups) == 0 {
|
if attr.Key == slog.TimeKey && len(groups) == 0 {
|
||||||
return slog.String(
|
return slog.String(slog.TimeKey, FormatTime(attr.Value.Time()))
|
||||||
slog.TimeKey,
|
|
||||||
FormatTime(attr.Value.Time()),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return attr
|
return attr
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return slog.New(handler).
|
return slog.New(handler).With("type", "process", "instance", instanceName)
|
||||||
With("type", "process", "instance", instanceName)
|
|
||||||
}
|
}
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
// Package routewatch asks a routewatch instance about client addresses, in the
|
|
||||||
// background, and keeps its answers in memory. A routewatch instance knows, for
|
|
||||||
// an address, the netblock it is announced in and the AS that originates it.
|
|
||||||
package routewatch
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/netip"
|
|
||||||
"net/url"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/hashicorp/golang-lru/v2/simplelru"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// answerKept is how long an answer is used after it came.
|
|
||||||
answerKept = time.Hour
|
|
||||||
// failureKept is how long routewatch is not asked again after a query
|
|
||||||
// fails, so that a dead routewatch is not asked on every request.
|
|
||||||
failureKept = time.Minute
|
|
||||||
// queryTimeout is how long one query may take. routewatch answers only
|
|
||||||
// after a reverse DNS lookup it allows 2 seconds, so this is longer.
|
|
||||||
queryTimeout = 5 * time.Second
|
|
||||||
// maxAnswers is how many answers are kept. Past it, the one that came
|
|
||||||
// longest ago is dropped.
|
|
||||||
maxAnswers = 100000
|
|
||||||
// maxQueries is how many queries may be under way at once. Past it, a
|
|
||||||
// client is not asked about until its next request, so that a swarm of
|
|
||||||
// new addresses cannot fill the memory.
|
|
||||||
maxQueries = 100
|
|
||||||
// maxAnswerBytes is the most of an answer that is read.
|
|
||||||
maxAnswerBytes = 64 << 10
|
|
||||||
)
|
|
||||||
|
|
||||||
// Missing is why Route gives no route. The request log's field
|
|
||||||
// routewatch_tags_missing gives these values.
|
|
||||||
type Missing string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// NoAnswerYet is a client routewatch has not answered about yet.
|
|
||||||
NoAnswerYet Missing = "no_answer_yet"
|
|
||||||
// RequestFailed is no answer because a recent query failed.
|
|
||||||
RequestFailed Missing = "request_failed"
|
|
||||||
// NoRoute is routewatch knowing no live route for the address.
|
|
||||||
NoRoute Missing = "no_route"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
errStatus = errors.New("routewatch answered")
|
|
||||||
errNoResult = errors.New("the answer holds no result")
|
|
||||||
errNotNetblock = errors.New("the answer's netblock is not a netblock")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Route is what routewatch knows of an address: the netblock it is announced
|
|
||||||
// in, and the AS that originates it.
|
|
||||||
type Route struct {
|
|
||||||
Netblock netip.Prefix
|
|
||||||
ASN uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
// Params are what New needs.
|
|
||||||
type Params struct {
|
|
||||||
// URL is where routewatch is (SWW_ROUTEWATCH_URL).
|
|
||||||
URL string
|
|
||||||
// Now tells the time, normally time.Now in UTC.
|
|
||||||
Now func() time.Time
|
|
||||||
// ProcessLog receives the query that fails, and why.
|
|
||||||
ProcessLog *slog.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client asks a routewatch instance about clients, in the background, and keeps
|
|
||||||
// its answers. It is safe for concurrent use.
|
|
||||||
type Client struct {
|
|
||||||
params Params
|
|
||||||
httpClient *http.Client
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
// answers are by client. Each is added as it comes and never moved up,
|
|
||||||
// so that the one that came longest ago is the first dropped.
|
|
||||||
answers *simplelru.LRU[netip.Prefix, answer]
|
|
||||||
// asking are the clients whose query is under way.
|
|
||||||
asking map[netip.Prefix]bool
|
|
||||||
// retryAt is when routewatch may be asked again after a query failed.
|
|
||||||
retryAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// answer is one kept answer: the route, or why there is none, and when it came.
|
|
||||||
type answer struct {
|
|
||||||
route Route
|
|
||||||
missing Missing
|
|
||||||
came time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// New returns a Client with no answer yet.
|
|
||||||
func New(params Params) *Client {
|
|
||||||
answers, err := simplelru.NewLRU[netip.Prefix, answer](maxAnswers, nil)
|
|
||||||
if err != nil {
|
|
||||||
panic(err) // NewLRU fails only for a size below one
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Client{
|
|
||||||
params: params,
|
|
||||||
httpClient: &http.Client{},
|
|
||||||
answers: answers,
|
|
||||||
asking: map[netip.Prefix]bool{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route returns what routewatch answered about client, an IPv4 address or an
|
|
||||||
// IPv6 group, or why it has no route. It never waits for a query. A client
|
|
||||||
// without an answer is asked about in the background, by addr, the address its
|
|
||||||
// request came from, unless its query is under way, a query failed less than
|
|
||||||
// failureKept ago, or maxQueries are under way. ctx is the context of the
|
|
||||||
// client's request, and a query goes on after the request ends.
|
|
||||||
func (c *Client) Route(
|
|
||||||
ctx context.Context, client netip.Prefix, addr netip.Addr,
|
|
||||||
) (Route, Missing) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
now := c.params.Now()
|
|
||||||
|
|
||||||
kept, found := c.answers.Peek(client)
|
|
||||||
if found && now.Sub(kept.came) < answerKept {
|
|
||||||
return kept.route, kept.missing
|
|
||||||
}
|
|
||||||
|
|
||||||
if now.Before(c.retryAt) {
|
|
||||||
return Route{}, RequestFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
if !c.asking[client] && len(c.asking) < maxQueries {
|
|
||||||
c.asking[client] = true
|
|
||||||
|
|
||||||
go c.ask(context.WithoutCancel(ctx), client, addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Route{}, NoAnswerYet
|
|
||||||
}
|
|
||||||
|
|
||||||
// ask asks routewatch about addr, keeps the answer as client's, and notes the
|
|
||||||
// query as no longer under way. A query that fails gives no answer, and
|
|
||||||
// routewatch is not asked again for failureKept. Only the first failure of
|
|
||||||
// those under way is logged.
|
|
||||||
func (c *Client) ask(ctx context.Context, client netip.Prefix, addr netip.Addr) {
|
|
||||||
route, missing, err := c.query(ctx, addr)
|
|
||||||
now := c.params.Now()
|
|
||||||
|
|
||||||
c.mu.Lock()
|
|
||||||
|
|
||||||
delete(c.asking, client)
|
|
||||||
|
|
||||||
firstFailure := err != nil && !now.Before(c.retryAt)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
c.answers.Add(client, answer{route: route, missing: missing, came: now})
|
|
||||||
} else {
|
|
||||||
c.retryAt = now.Add(failureKept)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mu.Unlock()
|
|
||||||
|
|
||||||
if firstFailure {
|
|
||||||
c.params.ProcessLog.Warn("asking routewatch failed", "error", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// query asks routewatch's IP info API about addr. An answer other than 200, one
|
|
||||||
// that cannot be read, and none within queryTimeout, fail. A result with no
|
|
||||||
// netblock is an address without a live route.
|
|
||||||
func (c *Client) query(ctx context.Context, addr netip.Addr) (Route, Missing, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, queryTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
endpoint, err := url.JoinPath(c.params.URL, "api/v1/ip", addr.String())
|
|
||||||
if err != nil {
|
|
||||||
return Route{}, "", fmt.Errorf("make the URL: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
|
|
||||||
if err != nil {
|
|
||||||
return Route{}, "", fmt.Errorf("make the request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
res, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
// Do's error names the URL, which holds the client's address, which
|
|
||||||
// is not to be logged: only what went wrong is kept.
|
|
||||||
return Route{}, "", fmt.Errorf("ask routewatch: %w", errors.Unwrap(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
_ = res.Body.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
if res.StatusCode != http.StatusOK {
|
|
||||||
return Route{}, "", fmt.Errorf("%w %s", errStatus, res.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
return readAnswer(io.LimitReader(res.Body, maxAnswerBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
// readAnswer reads routewatch's answer about one address.
|
|
||||||
func readAnswer(body io.Reader) (Route, Missing, error) {
|
|
||||||
var decoded struct {
|
|
||||||
Data struct {
|
|
||||||
Results []struct {
|
|
||||||
Netblock string `json:"netblock"`
|
|
||||||
ASN uint32 `json:"asn"`
|
|
||||||
} `json:"results"`
|
|
||||||
} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
err := json.NewDecoder(body).Decode(&decoded)
|
|
||||||
if err != nil {
|
|
||||||
return Route{}, "", fmt.Errorf("read the answer: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(decoded.Data.Results) == 0 {
|
|
||||||
return Route{}, "", errNoResult
|
|
||||||
}
|
|
||||||
|
|
||||||
result := decoded.Data.Results[0]
|
|
||||||
if result.Netblock == "" {
|
|
||||||
return Route{}, NoRoute, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
netblock, err := netip.ParsePrefix(result.Netblock)
|
|
||||||
if err != nil {
|
|
||||||
return Route{}, "", fmt.Errorf("%w: %q", errNotNetblock, result.Netblock)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Route{Netblock: netblock.Masked(), ASN: result.ASN}, "", nil
|
|
||||||
}
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
package routewatch_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/netip"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/routewatch"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
clientAddr = "192.0.2.1"
|
|
||||||
routedAnswer = `{"status":"ok","data":{"results":[` +
|
|
||||||
`{"ip":"192.0.2.1","netblock":"192.0.2.0/24","asn":64496}]}}`
|
|
||||||
noRouteAnswer = `{"status":"ok","data":{"results":[{"ip":"192.0.2.1"}]}}`
|
|
||||||
// slowAnswer is longer than routewatch allows a reverse DNS lookup.
|
|
||||||
slowAnswer = 2500 * time.Millisecond
|
|
||||||
// waitLimit is longer than the Client allows a query.
|
|
||||||
waitLimit = 10 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// clock is a time the tests move by hand.
|
|
||||||
type clock struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
now time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *clock) Now() time.Time {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
return c.now
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *clock) advance(by time.Duration) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
c.now = c.now.Add(by)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fixture is a Client asking a stand-in for routewatch. queries counts what the
|
|
||||||
// stand-in was asked, paths holds the last path it was asked for, and log holds
|
|
||||||
// the process log.
|
|
||||||
type fixture struct {
|
|
||||||
t *testing.T
|
|
||||||
server *httptest.Server
|
|
||||||
client *routewatch.Client
|
|
||||||
clk *clock
|
|
||||||
queries *atomic.Int32
|
|
||||||
path *atomic.Value
|
|
||||||
log *syncBuffer
|
|
||||||
}
|
|
||||||
|
|
||||||
// syncBuffer is a buffer the background queries can log to while a test reads it.
|
|
||||||
type syncBuffer struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
buf bytes.Buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
return b.buf.Write(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *syncBuffer) String() string {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
return b.buf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func newFixture(t *testing.T, standIn http.HandlerFunc) *fixture {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
f := &fixture{
|
|
||||||
t: t,
|
|
||||||
clk: &clock{now: time.Date(2026, 11, 9, 0, 0, 0, 0, time.UTC)},
|
|
||||||
queries: &atomic.Int32{},
|
|
||||||
path: &atomic.Value{},
|
|
||||||
log: &syncBuffer{},
|
|
||||||
}
|
|
||||||
|
|
||||||
f.server = httptest.NewServer(http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
f.queries.Add(1)
|
|
||||||
f.path.Store(r.URL.Path)
|
|
||||||
standIn(w, r)
|
|
||||||
}))
|
|
||||||
t.Cleanup(f.server.Close)
|
|
||||||
|
|
||||||
f.client = routewatch.New(routewatch.Params{
|
|
||||||
URL: f.server.URL + "/", Now: f.clk.Now,
|
|
||||||
ProcessLog: slog.New(slog.NewTextHandler(f.log, nil)),
|
|
||||||
})
|
|
||||||
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
func answering(body string) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
_, _ = w.Write([]byte(body))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fixture) route() (routewatch.Route, routewatch.Missing) {
|
|
||||||
addr := netip.MustParseAddr(clientAddr)
|
|
||||||
client := netip.PrefixFrom(addr, addr.BitLen())
|
|
||||||
|
|
||||||
return f.client.Route(context.Background(), client, addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitFor asks until the answer is why, or, while why is "", until there is a
|
|
||||||
// route, and returns the route.
|
|
||||||
func (f *fixture) waitFor(why routewatch.Missing) routewatch.Route {
|
|
||||||
f.t.Helper()
|
|
||||||
|
|
||||||
deadline := time.Now().Add(waitLimit)
|
|
||||||
|
|
||||||
for {
|
|
||||||
route, missing := f.route()
|
|
||||||
if missing == why {
|
|
||||||
return route
|
|
||||||
}
|
|
||||||
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
f.t.Fatalf("still %q after %s, want %q", missing, waitLimit, why)
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantFailureLoggedWithoutTheClient waits for the process log to name a failed
|
|
||||||
// query, and checks that it does not name the client.
|
|
||||||
func (f *fixture) wantFailureLoggedWithoutTheClient() {
|
|
||||||
f.t.Helper()
|
|
||||||
|
|
||||||
// The failure is noted before it is logged.
|
|
||||||
deadline := time.Now().Add(waitLimit)
|
|
||||||
for !strings.Contains(f.log.String(), "asking routewatch failed") {
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
f.t.Fatalf("process log %q names no failure", f.log.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(f.log.String(), clientAddr) {
|
|
||||||
f.t.Errorf("process log %q names the client", f.log.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRouteGivesRoutewatchsAnswerAndAsksByAddress(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newFixture(t, answering(routedAnswer))
|
|
||||||
|
|
||||||
if _, missing := f.route(); missing != routewatch.NoAnswerYet {
|
|
||||||
t.Errorf("before the answer: %q, want %q", missing, routewatch.NoAnswerYet)
|
|
||||||
}
|
|
||||||
|
|
||||||
route := f.waitFor("")
|
|
||||||
|
|
||||||
want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496}
|
|
||||||
if route != want {
|
|
||||||
t.Errorf("route %+v, want %+v", route, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := f.path.Load(); got != "/api/v1/ip/"+clientAddr {
|
|
||||||
t.Errorf("asked for path %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnswerAfterASlowReverseDNSLookupIsKept(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
time.Sleep(slowAnswer)
|
|
||||||
|
|
||||||
_, _ = w.Write([]byte(routedAnswer))
|
|
||||||
})
|
|
||||||
|
|
||||||
f.route()
|
|
||||||
|
|
||||||
route := f.waitFor("")
|
|
||||||
|
|
||||||
want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496}
|
|
||||||
if route != want {
|
|
||||||
t.Errorf("route %+v, want %+v", route, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddressWithoutARouteIsKeptAsNoRoute(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newFixture(t, answering(noRouteAnswer))
|
|
||||||
|
|
||||||
f.route()
|
|
||||||
f.waitFor(routewatch.NoRoute)
|
|
||||||
|
|
||||||
if got := f.queries.Load(); got != 1 {
|
|
||||||
t.Errorf("%d queries, want 1", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnswerRoutewatchCannotBeTakenFromFails(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := map[string]http.HandlerFunc{
|
|
||||||
"500": func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
},
|
|
||||||
"not JSON": answering("no"),
|
|
||||||
"no results": answering(`{"status":"ok","data":{"results":[]}}`),
|
|
||||||
"bad netblock": answering(
|
|
||||||
`{"status":"ok","data":{"results":[{"netblock":"nowhere","asn":1}]}}`),
|
|
||||||
}
|
|
||||||
for name, standIn := range tests {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newFixture(t, standIn)
|
|
||||||
|
|
||||||
f.route()
|
|
||||||
f.waitFor(routewatch.RequestFailed)
|
|
||||||
f.wantFailureLoggedWithoutTheClient()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefusedConnectionFailsTheQueryAndTheLogOmitsTheClient(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newFixture(t, answering(routedAnswer))
|
|
||||||
|
|
||||||
f.server.Close()
|
|
||||||
|
|
||||||
f.route()
|
|
||||||
f.waitFor(routewatch.RequestFailed)
|
|
||||||
f.wantFailureLoggedWithoutTheClient()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFailureIsKeptForAMinuteSoRoutewatchIsNotAskedPerRequest(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
})
|
|
||||||
|
|
||||||
f.route()
|
|
||||||
f.waitFor(routewatch.RequestFailed)
|
|
||||||
f.route()
|
|
||||||
|
|
||||||
if got := f.queries.Load(); got != 1 {
|
|
||||||
t.Errorf("%d queries while the failure is kept, want 1", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
f.clk.advance(time.Minute)
|
|
||||||
f.route()
|
|
||||||
|
|
||||||
deadline := time.Now().Add(waitLimit)
|
|
||||||
for f.queries.Load() != 2 {
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatalf("%d queries after the failure expired, want 2", f.queries.Load())
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHangingRoutewatchNeverDelaysAndFailsTheQuery(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
f := newFixture(t, func(_ http.ResponseWriter, r *http.Request) {
|
|
||||||
<-r.Context().Done()
|
|
||||||
})
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
f.route()
|
|
||||||
|
|
||||||
if elapsed := time.Since(start); elapsed > time.Second {
|
|
||||||
t.Errorf("Route took %s while routewatch hangs", elapsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
f.waitFor(routewatch.RequestFailed)
|
|
||||||
f.wantFailureLoggedWithoutTheClient()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRepeatedRequestsMakeOneQueryAndTheAnswerExpires(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
release := make(chan struct{})
|
|
||||||
f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
<-release
|
|
||||||
|
|
||||||
_, _ = w.Write([]byte(routedAnswer))
|
|
||||||
})
|
|
||||||
|
|
||||||
for range 50 {
|
|
||||||
f.route()
|
|
||||||
}
|
|
||||||
|
|
||||||
close(release)
|
|
||||||
f.waitFor("")
|
|
||||||
|
|
||||||
for range 50 {
|
|
||||||
f.route()
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := f.queries.Load(); got != 1 {
|
|
||||||
t.Errorf("%d queries for repeated requests, want 1", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
f.clk.advance(time.Hour)
|
|
||||||
f.waitFor(routewatch.NoAnswerYet)
|
|
||||||
f.waitFor("")
|
|
||||||
|
|
||||||
if got := f.queries.Load(); got != 2 {
|
|
||||||
t.Errorf("%d queries after the answer expired, want 2", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// peerAddress is the address of the request's TCP peer, normally traefik, without
|
// peerAddress is the address of the request's TCP peer, normally traefik, without
|
||||||
// any IPv6 zone, which discriminator refuses.
|
// any IPv6 zone, which servewaf refuses.
|
||||||
func peerAddress(r *http.Request) netip.Addr {
|
func peerAddress(r *http.Request) netip.Addr {
|
||||||
addrPort, err := netip.ParseAddrPort(r.RemoteAddr)
|
addrPort, err := netip.ParseAddrPort(r.RemoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -34,7 +34,7 @@ func peerAddress(r *http.Request) netip.Addr {
|
|||||||
// address outside them is the visitor; if every address in it is inside, the
|
// address outside them is the visitor; if every address in it is inside, the
|
||||||
// leftmost is, and with no header, the peer. An entry that is not an address
|
// leftmost is, and with no header, the peer. An entry that is not an address
|
||||||
// ends the reading, since nothing to its left can be believed. The visitor's
|
// ends the reading, since nothing to its left can be believed. The visitor's
|
||||||
// address never has an IPv6 zone, which discriminator refuses.
|
// address never has an IPv6 zone, which servewaf refuses.
|
||||||
func clientAddress(
|
func clientAddress(
|
||||||
peer netip.Addr, forwardedFor []string, trusted []netip.Prefix,
|
peer netip.Addr, forwardedFor []string, trusted []netip.Prefix,
|
||||||
) netip.Addr {
|
) netip.Addr {
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// post is the start of a POST whose headers end with what follows it.
|
// post is the start of a POST whose headers end with what follows it.
|
||||||
@@ -58,7 +58,7 @@ func TestServerHasTheFixedLimits(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestABodyAnnouncedOverItsLimitIsRefusedOnceDiscriminatorAllowsIt(t *testing.T) {
|
func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -71,7 +71,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceDiscriminatorAllowsIt(t *testing
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
t.Error("the app was asked")
|
t.Error("the app was asked")
|
||||||
})
|
})
|
||||||
@@ -100,7 +100,7 @@ func TestABodyAnnouncedOverItsLimitIsRefusedOnceDiscriminatorAllowsIt(t *testing
|
|||||||
func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
|
func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, deny)
|
standIn := startServeWAF(t, deny)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
maxRequestSize: "1K",
|
maxRequestSize: "1K",
|
||||||
@@ -109,7 +109,7 @@ func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
|
|||||||
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
|
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
|
||||||
bytes.NewReader(make([]byte, 1025))))
|
bytes.NewReader(make([]byte, 1025))))
|
||||||
if got.status != http.StatusForbidden {
|
if got.status != http.StatusForbidden {
|
||||||
t.Errorf("answered %+v, want discriminator's 403", got)
|
t.Errorf("answered %+v, want servewaf's 403", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
|
|||||||
|
|
||||||
var asked atomic.Bool
|
var asked atomic.Bool
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
asked.Store(true)
|
asked.Store(true)
|
||||||
greet(w, r)
|
greet(w, r)
|
||||||
@@ -165,7 +165,7 @@ func TestAResponseOverItsLimit(t *testing.T) {
|
|||||||
t.Run("announced, is refused", func(t *testing.T) {
|
t.Run("announced, is refused", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set(contentLengthHeader, strconv.Itoa(len(large)))
|
w.Header().Set(contentLengthHeader, strconv.Itoa(len(large)))
|
||||||
_, _ = io.WriteString(w, large)
|
_, _ = io.WriteString(w, large)
|
||||||
@@ -187,7 +187,7 @@ func TestAResponseOverItsLimit(t *testing.T) {
|
|||||||
t.Run("not announced, is cut off", func(t *testing.T) {
|
t.Run("not announced, is cut off", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
_, _ = io.WriteString(w, large[:1])
|
_, _ = io.WriteString(w, large[:1])
|
||||||
_ = http.NewResponseController(w).Flush()
|
_ = http.NewResponseController(w).Flush()
|
||||||
@@ -217,7 +217,7 @@ func TestAHeadWhoseAnswerAnnouncesMoreThanTheResponseLimitIsPassedOn(t *testing.
|
|||||||
|
|
||||||
const announced = "2048"
|
const announced = "2048"
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set(contentLengthHeader, announced)
|
w.Header().Set(contentLengthHeader, announced)
|
||||||
})
|
})
|
||||||
@@ -240,7 +240,7 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) {
|
|||||||
t.Run(setting, func(t *testing.T) {
|
t.Run(setting, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
setting: "off",
|
setting: "off",
|
||||||
@@ -251,7 +251,7 @@ func TestASettingOfOffSetsNoLimit(t *testing.T) {
|
|||||||
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
|
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
|
||||||
strings.NewReader("a body"))))
|
strings.NewReader("a body"))))
|
||||||
|
|
||||||
if got := get(t, addr, discriminator.HealthPath); got.status != http.StatusOK {
|
if got := get(t, addr, servewaf.HealthPath); got.status != http.StatusOK {
|
||||||
t.Errorf("answered %+v, want rproxy's own 200", got)
|
t.Errorf("answered %+v, want rproxy's own 200", got)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -270,7 +270,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
maxRequestTime: "500",
|
maxRequestTime: "500",
|
||||||
@@ -289,7 +289,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(standIn.questions) != 0 {
|
if len(standIn.questions) != 0 {
|
||||||
t.Error("discriminator was asked")
|
t.Error("servewaf was asked")
|
||||||
}
|
}
|
||||||
|
|
||||||
standIn.wantNoReport(t)
|
standIn.wantNoReport(t)
|
||||||
@@ -300,7 +300,7 @@ func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
|||||||
func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
|
func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
@@ -314,7 +314,7 @@ func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(standIn.questions) != 0 {
|
if len(standIn.questions) != 0 {
|
||||||
t.Error("discriminator was asked")
|
t.Error("servewaf was asked")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,7 +334,7 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
@@ -365,7 +365,7 @@ func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(standIn.questions) != 0 {
|
if len(standIn.questions) != 0 {
|
||||||
t.Error("discriminator was asked")
|
t.Error("servewaf was asked")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -404,7 +404,7 @@ func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) {
|
|||||||
announced = 100 * questionBodyMaxBytes
|
announced = 100 * questionBodyMaxBytes
|
||||||
)
|
)
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
})
|
})
|
||||||
@@ -448,13 +448,13 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
|
|||||||
name, target string
|
name, target string
|
||||||
status int
|
status int
|
||||||
}{
|
}{
|
||||||
{"discriminator's deny", "/", http.StatusForbidden},
|
{"servewaf's deny", "/", http.StatusForbidden},
|
||||||
{"rproxy's own 400", "x:admin", http.StatusBadRequest},
|
{"rproxy's own 400", "x:admin", http.StatusBadRequest},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, deny)
|
standIn := startServeWAF(t, deny)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
_, _, server := startServer(t, standIn.URL, app.URL, map[string]string{
|
_, _, server := startServer(t, standIn.URL, app.URL, map[string]string{
|
||||||
maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
|
maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
|
||||||
@@ -484,7 +484,7 @@ func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
|
|||||||
func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
|
func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
maxRequestTime: "3000",
|
maxRequestTime: "3000",
|
||||||
@@ -553,7 +553,7 @@ func TestAnAppTooSlowToTakeTheRequestIsAnswered504(t *testing.T) {
|
|||||||
writeSize = 32 << 10
|
writeSize = 32 << 10
|
||||||
)
|
)
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
addr, _ := startRProxy(t, standIn.URL, startAppThatTakesNothing(t),
|
addr, _ := startRProxy(t, standIn.URL, startAppThatTakesNothing(t),
|
||||||
map[string]string{maxRequestTime: "3000"})
|
map[string]string{maxRequestTime: "3000"})
|
||||||
|
|
||||||
@@ -580,7 +580,7 @@ func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
<-release
|
<-release
|
||||||
})
|
})
|
||||||
@@ -607,7 +607,7 @@ func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
|
|||||||
const start = "the start"
|
const start = "the start"
|
||||||
|
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
_, _ = io.WriteString(w, start)
|
_, _ = io.WriteString(w, start)
|
||||||
_ = http.NewResponseController(w).Flush()
|
_ = http.NewResponseController(w).Flush()
|
||||||
@@ -656,7 +656,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
|
|||||||
writeSize = 32 << 10
|
writeSize = 32 << 10
|
||||||
)
|
)
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
part := make([]byte, writeSize)
|
part := make([]byte, writeSize)
|
||||||
|
|
||||||
@@ -693,7 +693,7 @@ func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
|
|||||||
func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
|
func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
||||||
|
|
||||||
got := get(t, addr, "/")
|
got := get(t, addr, "/")
|
||||||
@@ -709,7 +709,7 @@ func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
|
|||||||
func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) {
|
func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
_, _ = io.WriteString(w, greeting)
|
_, _ = io.WriteString(w, greeting)
|
||||||
})
|
})
|
||||||
@@ -732,7 +732,7 @@ func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
working := make(chan struct{})
|
working := make(chan struct{})
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||||
close(working)
|
close(working)
|
||||||
<-r.Context().Done()
|
<-r.Context().Done()
|
||||||
@@ -773,7 +773,7 @@ func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing
|
|||||||
|
|
||||||
const answer = "the end of a long answer"
|
const answer = "the end of a long answer"
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = io.Copy(io.Discard, r.Body)
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
_ = http.NewResponseController(w).Flush()
|
_ = http.NewResponseController(w).Flush()
|
||||||
@@ -802,14 +802,14 @@ func TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline(t *testing
|
|||||||
{"a deny", post, http.StatusForbidden},
|
{"a deny", post, http.StatusForbidden},
|
||||||
{
|
{
|
||||||
"the health check",
|
"the health check",
|
||||||
"GET " + discriminator.HealthPath + " HTTP/1.1\r\nHost: site.example\r\n",
|
"GET " + servewaf.HealthPath + " HTTP/1.1\r\nHost: site.example\r\n",
|
||||||
http.StatusOK,
|
http.StatusOK,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, deny)
|
standIn := startServeWAF(t, deny)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
// The time limit is long enough for a loaded host to read the first part of
|
// The time limit is long enough for a loaded host to read the first part of
|
||||||
// the body before it runs out.
|
// the body before it runs out.
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// questionBodyMaxBytes is the most of a body a question carries.
|
// questionBodyMaxBytes is the most of a body a question carries.
|
||||||
const questionBodyMaxBytes = discriminator.QuestionBodyMaxBytes
|
const questionBodyMaxBytes = servewaf.QuestionBodyMaxBytes
|
||||||
|
|
||||||
// chunked is the encoding of a body whose length the visitor did not announce.
|
// chunked is the encoding of a body whose length the visitor did not announce.
|
||||||
const chunked = "chunked"
|
const chunked = "chunked"
|
||||||
@@ -29,7 +29,7 @@ const pathGoReencodes = "/some%2Fpath/with%20space|pipe"
|
|||||||
func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
|
func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
@@ -42,9 +42,9 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
|
|||||||
req.Header.Set(forwardedForHeader, "198.51.100.1")
|
req.Header.Set(forwardedForHeader, "198.51.100.1")
|
||||||
req.Header.Set(forwardedProtoHeader, "gopher")
|
req.Header.Set(forwardedProtoHeader, "gopher")
|
||||||
req.Header.Set(requestIDHeader, "the-visitor's")
|
req.Header.Set(requestIDHeader, "the-visitor's")
|
||||||
req.Header.Set(discriminator.ClientHeader, "198.51.100.2")
|
req.Header.Set(servewaf.ClientHeader, "198.51.100.2")
|
||||||
req.Header.Set(discriminator.PeerHeader, "198.51.100.3")
|
req.Header.Set(servewaf.PeerHeader, "198.51.100.3")
|
||||||
req.Header.Set(discriminator.ContentLengthHeader, "12345")
|
req.Header.Set(servewaf.ContentLengthHeader, "12345")
|
||||||
// Go's client then sends no User-Agent, so that one in the question would be
|
// Go's client then sends no User-Agent, so that one in the question would be
|
||||||
// Go's own.
|
// Go's own.
|
||||||
req.Header["User-Agent"] = nil
|
req.Header["User-Agent"] = nil
|
||||||
@@ -68,8 +68,8 @@ func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
|
|||||||
want.Set(forwardedForHeader, "198.51.100.1")
|
want.Set(forwardedForHeader, "198.51.100.1")
|
||||||
want.Set(forwardedProtoHeader, "http")
|
want.Set(forwardedProtoHeader, "http")
|
||||||
want.Set(requestIDHeader, asked.header.Get(requestIDHeader))
|
want.Set(requestIDHeader, asked.header.Get(requestIDHeader))
|
||||||
want.Set(discriminator.ClientHeader, localhost)
|
want.Set(servewaf.ClientHeader, localhost)
|
||||||
want.Set(discriminator.PeerHeader, localhost)
|
want.Set(servewaf.PeerHeader, localhost)
|
||||||
|
|
||||||
if !maps.EqualFunc(asked.header, want, slices.Equal) {
|
if !maps.EqualFunc(asked.header, want, slices.Equal) {
|
||||||
t.Errorf("the question's headers are %v, want %v", asked.header, want)
|
t.Errorf("the question's headers are %v, want %v", asked.header, want)
|
||||||
@@ -93,7 +93,7 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
forwarded := make(chan http.Header, 1)
|
forwarded := make(chan http.Header, 1)
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
forwarded <- r.Header.Clone()
|
forwarded <- r.Header.Clone()
|
||||||
@@ -110,8 +110,8 @@ func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
|
|||||||
wantGreeting(t, visit(t, req))
|
wantGreeting(t, visit(t, req))
|
||||||
|
|
||||||
asked := standIn.question(t)
|
asked := standIn.question(t)
|
||||||
if asked.header.Get(discriminator.ClientHeader) != tc.client ||
|
if asked.header.Get(servewaf.ClientHeader) != tc.client ||
|
||||||
asked.header.Get(discriminator.PeerHeader) != localhost ||
|
asked.header.Get(servewaf.PeerHeader) != localhost ||
|
||||||
asked.header.Get(forwardedForHeader) != visitor ||
|
asked.header.Get(forwardedForHeader) != visitor ||
|
||||||
asked.header.Get(forwardedProtoHeader) != tc.proto {
|
asked.header.Get(forwardedProtoHeader) != tc.proto {
|
||||||
t.Errorf("the question's headers are %v", asked.header)
|
t.Errorf("the question's headers are %v", asked.header)
|
||||||
@@ -157,7 +157,7 @@ func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
trustedProxies: localhost + "/32,10.0.0.0/8",
|
trustedProxies: localhost + "/32,10.0.0.0/8",
|
||||||
@@ -167,8 +167,7 @@ func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) {
|
|||||||
req.Header[forwardedForHeader] = tc.header
|
req.Header[forwardedForHeader] = tc.header
|
||||||
wantGreeting(t, visit(t, req))
|
wantGreeting(t, visit(t, req))
|
||||||
|
|
||||||
if got := standIn.question(t).header.Get(discriminator.ClientHeader); got !=
|
if got := standIn.question(t).header.Get(servewaf.ClientHeader); got != tc.client {
|
||||||
tc.client {
|
|
||||||
t.Errorf("the question names %s, want %s", got, tc.client)
|
t.Errorf("the question names %s, want %s", got, tc.client)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -183,7 +182,7 @@ func randomBytes(n int) []byte {
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFirst64KiBReachDiscriminatorAndTheWholeBodyTheApp(t *testing.T) {
|
func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, size := range []int{
|
for _, size := range []int{
|
||||||
@@ -198,7 +197,7 @@ func TestFirst64KiBReachDiscriminatorAndTheWholeBodyTheApp(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
body := randomBytes(size)
|
body := randomBytes(size)
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
received := make(chan []byte, 1)
|
received := make(chan []byte, 1)
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
whole, _ := io.ReadAll(r.Body)
|
whole, _ := io.ReadAll(r.Body)
|
||||||
@@ -244,7 +243,7 @@ func wantQuestionBody(
|
|||||||
len(want))
|
len(want))
|
||||||
}
|
}
|
||||||
|
|
||||||
values := asked.header.Values(discriminator.ContentLengthHeader)
|
values := asked.header.Values(servewaf.ContentLengthHeader)
|
||||||
|
|
||||||
if announced {
|
if announced {
|
||||||
if !slices.Equal(values, []string{strconv.Itoa(size)}) ||
|
if !slices.Equal(values, []string{strconv.Itoa(size)}) ||
|
||||||
@@ -265,7 +264,7 @@ func wantQuestionBody(
|
|||||||
func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
|
func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, deny)
|
standIn := startServeWAF(t, deny)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
@@ -276,7 +275,7 @@ func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
|
|||||||
strings.Repeat("x", questionBodyMaxBytes))
|
strings.Repeat("x", questionBodyMaxBytes))
|
||||||
|
|
||||||
if status := readStatus(t, conn); status != http.StatusForbidden {
|
if status := readStatus(t, conn); status != http.StatusForbidden {
|
||||||
t.Errorf("status %d, want discriminator's 403", status)
|
t.Errorf("status %d, want servewaf's 403", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if report := standIn.report(t); report.RequestBytes != questionBodyMaxBytes {
|
if report := standIn.report(t); report.RequestBytes != questionBodyMaxBytes {
|
||||||
@@ -307,14 +306,14 @@ func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) {
|
|||||||
t.Run(method+" "+tc.name, func(t *testing.T) {
|
t.Run(method+" "+tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, deny)
|
standIn := startServeWAF(t, deny)
|
||||||
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
||||||
|
|
||||||
conn := dial(t, addr)
|
conn := dial(t, addr)
|
||||||
send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing)
|
send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing)
|
||||||
|
|
||||||
if status := readStatus(t, conn); status != http.StatusForbidden {
|
if status := readStatus(t, conn); status != http.StatusForbidden {
|
||||||
t.Errorf("status %d, want discriminator's 403", status)
|
t.Errorf("status %d, want servewaf's 403", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
asked := standIn.question(t)
|
asked := standIn.question(t)
|
||||||
@@ -335,7 +334,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
|
|||||||
t.Run(method, func(t *testing.T) {
|
t.Run(method, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
t.Error("the app was asked")
|
t.Error("the app was asked")
|
||||||
})
|
})
|
||||||
@@ -354,7 +353,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(standIn.questions) != 0 {
|
if len(standIn.questions) != 0 {
|
||||||
t.Error("discriminator was asked")
|
t.Error("servewaf was asked")
|
||||||
}
|
}
|
||||||
|
|
||||||
standIn.wantNoReport(t)
|
standIn.wantNoReport(t)
|
||||||
@@ -362,7 +361,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
|
func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, method := range []string{
|
for _, method := range []string{
|
||||||
@@ -373,7 +372,7 @@ func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
reached := make(chan string, 1)
|
reached := make(chan string, 1)
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||||
reached <- r.Method
|
reached <- r.Method
|
||||||
})
|
})
|
||||||
@@ -385,7 +384,7 @@ func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if asked := standIn.question(t); asked.method != method {
|
if asked := standIn.question(t); asked.method != method {
|
||||||
t.Errorf("discriminator was asked about %s", asked.method)
|
t.Errorf("servewaf was asked about %s", asked.method)
|
||||||
}
|
}
|
||||||
|
|
||||||
if atApp := <-reached; atApp != method {
|
if atApp := <-reached; atApp != method {
|
||||||
@@ -395,7 +394,7 @@ func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) {
|
func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const host = "Host: site.example\r\n\r\n"
|
const host = "Host: site.example\r\n\r\n"
|
||||||
@@ -417,7 +416,7 @@ func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) {
|
|||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
@@ -435,7 +434,7 @@ func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(standIn.questions) != 0 {
|
if len(standIn.questions) != 0 {
|
||||||
t.Error("discriminator was asked")
|
t.Error("servewaf was asked")
|
||||||
}
|
}
|
||||||
|
|
||||||
standIn.wantNoReport(t)
|
standIn.wantNoReport(t)
|
||||||
@@ -443,12 +442,12 @@ func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAHostWithAPercentSignButNoBracketGoesToDiscriminator(t *testing.T) {
|
func TestAHostWithAPercentSignButNoBracketGoesToServeWAF(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const host = "a%b"
|
const host = "a%b"
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
@@ -469,7 +468,7 @@ func TestATrustedPeerWithNoForwardedProtoIsAskedAboutAsHTTP(t *testing.T) {
|
|||||||
|
|
||||||
const want = "http"
|
const want = "http"
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
trustedProxies: localhost + "/32",
|
trustedProxies: localhost + "/32",
|
||||||
@@ -498,7 +497,7 @@ func TestAQuestionNamesNoZone(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, deny)
|
standIn := startServeWAF(t, deny)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
_, out, server := startServer(t, standIn.URL, app.URL, map[string]string{
|
_, out, server := startServer(t, standIn.URL, app.URL, map[string]string{
|
||||||
trustedProxies: localhost + "/32",
|
trustedProxies: localhost + "/32",
|
||||||
@@ -511,8 +510,8 @@ func TestAQuestionNamesNoZone(t *testing.T) {
|
|||||||
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
asked := standIn.question(t)
|
asked := standIn.question(t)
|
||||||
if asked.header.Get(discriminator.ClientHeader) != tc.client ||
|
if asked.header.Get(servewaf.ClientHeader) != tc.client ||
|
||||||
asked.header.Get(discriminator.PeerHeader) != tc.peer {
|
asked.header.Get(servewaf.PeerHeader) != tc.peer {
|
||||||
t.Errorf("the question's headers are %v", asked.header)
|
t.Errorf("the question's headers are %v", asked.header)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-11
@@ -16,16 +16,16 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rules"
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// flushAfterEachWrite has ReverseProxy pass on each part of the app's answer as
|
// flushAfterEachWrite has ReverseProxy pass on each part of the app's answer as
|
||||||
// soon as it arrives.
|
// soon as it arrives.
|
||||||
const flushAfterEachWrite time.Duration = -1
|
const flushAfterEachWrite time.Duration = -1
|
||||||
|
|
||||||
// reportStatusMax is the highest status discriminator takes in a report. An app can
|
// reportStatusMax is the highest status servewaf takes in a report. An app can
|
||||||
// send a higher one, up to 999, and the visitor gets that as it is.
|
// send a higher one, up to 999, and the visitor gets that as it is.
|
||||||
const reportStatusMax = 599
|
const reportStatusMax = 599
|
||||||
|
|
||||||
@@ -49,14 +49,14 @@ type request struct {
|
|||||||
peerTrusted bool
|
peerTrusted bool
|
||||||
start time.Time
|
start time.Time
|
||||||
|
|
||||||
// disposition is what is done with the request: discriminator's valid disposition,
|
// disposition is what is done with the request: servewaf's valid disposition,
|
||||||
// or SWRP_DEFAULT_DISPOSITION. It is "" when neither gave one.
|
// or SWRP_DEFAULT_DISPOSITION. It is "" when neither gave one.
|
||||||
disposition string
|
disposition string
|
||||||
// answered is true when discriminator gave disposition, which makes a report due,
|
// answered is true when servewaf gave disposition, which makes a report due,
|
||||||
// and byDefault when SWRP_DEFAULT_DISPOSITION did.
|
// and byDefault when SWRP_DEFAULT_DISPOSITION did.
|
||||||
answered bool
|
answered bool
|
||||||
byDefault bool
|
byDefault bool
|
||||||
// asn and country are discriminator's X-Client-ASN and X-Client-Country, for the
|
// asn and country are servewaf's X-Client-ASN and X-Client-Country, for the
|
||||||
// app.
|
// app.
|
||||||
asn, country string
|
asn, country string
|
||||||
|
|
||||||
@@ -122,14 +122,14 @@ func (rq *request) answerHealth() {
|
|||||||
// visitor too slow to send it is answered 408. For a malformed chunk, or a
|
// visitor too slow to send it is answered 408. For a malformed chunk, or a
|
||||||
// visitor that went away or stopped its sending side, the connection is closed
|
// visitor that went away or stopped its sending side, the connection is closed
|
||||||
// without an answer. A body over SWRP_MAX_REQUEST_SIZE goes on with the part
|
// without an answer. A body over SWRP_MAX_REQUEST_SIZE goes on with the part
|
||||||
// read, so that discriminator is asked first; passToApp then refuses it.
|
// read, so that servewaf is asked first; passToApp then refuses it.
|
||||||
func (rq *request) readFirstPartOfBody() bool {
|
func (rq *request) readFirstPartOfBody() bool {
|
||||||
if rq.body == nil {
|
if rq.body == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = rq.rc.SetReadDeadline(rq.requestDeadline())
|
_ = rq.rc.SetReadDeadline(rq.requestDeadline())
|
||||||
held, err := io.ReadAll(io.LimitReader(rq.body, discriminator.QuestionBodyMaxBytes))
|
held, err := io.ReadAll(io.LimitReader(rq.body, servewaf.QuestionBodyMaxBytes))
|
||||||
// From here on, the request timer ends a read of the rest of the body, so that
|
// From here on, the request timer ends a read of the rest of the body, so that
|
||||||
// the answer can name the side rproxy was waiting on.
|
// the answer can name the side rproxy was waiting on.
|
||||||
_ = rq.rc.SetReadDeadline(time.Time{})
|
_ = rq.rc.SetReadDeadline(time.Time{})
|
||||||
@@ -192,7 +192,7 @@ func (rq *request) forward(ctx context.Context) {
|
|||||||
// rewrite makes the request the app receives: the visitor's request, unchanged,
|
// rewrite makes the request the app receives: the visitor's request, unchanged,
|
||||||
// sent to SWRP_UPSTREAM_URL, with the forwarded headers and the request's id set,
|
// sent to SWRP_UPSTREAM_URL, with the forwarded headers and the request's id set,
|
||||||
// without any X-Client-ASN or X-Client-Country the visitor sent, and with
|
// without any X-Client-ASN or X-Client-Country the visitor sent, and with
|
||||||
// discriminator's.
|
// servewaf's.
|
||||||
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
|
||||||
upstream := rq.h.config.UpstreamURL
|
upstream := rq.h.config.UpstreamURL
|
||||||
pr.Out.URL.Scheme = upstream.Scheme
|
pr.Out.URL.Scheme = upstream.Scheme
|
||||||
@@ -312,7 +312,7 @@ func (rq *request) answer(status int) {
|
|||||||
// handler has returned, after finish has written the log line and sent the report.
|
// handler has returned, after finish has written the log line and sent the report.
|
||||||
// Without Content-Length, the flush would send the body in chunks, the last of
|
// Without Content-Length, the flush would send the body in chunks, the last of
|
||||||
// which would still wait for the handler. A HEAD's answer has no body, so any
|
// which would still wait for the handler. A HEAD's answer has no body, so any
|
||||||
// Content-Length it has stays as discriminator gave it.
|
// Content-Length it has stays as servewaf gave it.
|
||||||
func (rq *request) send(status int, body []byte) {
|
func (rq *request) send(status int, body []byte) {
|
||||||
if rq.in.Method != http.MethodHead {
|
if rq.in.Method != http.MethodHead {
|
||||||
rq.out.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
rq.out.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||||
@@ -347,7 +347,7 @@ func (rq *request) refuse(status int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// finish ends the request's time limits, writes its log line and, if discriminator
|
// finish ends the request's time limits, writes its log line and, if servewaf
|
||||||
// answered the question, sends the report. ctx is the request's own context.
|
// answered the question, sends the report. ctx is the request's own context.
|
||||||
func (rq *request) finish(ctx context.Context) {
|
func (rq *request) finish(ctx context.Context) {
|
||||||
rq.stopTimers()
|
rq.stopTimers()
|
||||||
@@ -384,7 +384,7 @@ func (rq *request) finish(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
report := &discriminator.Report{
|
report := &servewaf.Report{
|
||||||
RequestID: rq.id,
|
RequestID: rq.id,
|
||||||
Status: min(rq.out.status, reportStatusMax),
|
Status: min(rq.out.status, reportStatusMax),
|
||||||
RequestBytes: requestBytes,
|
RequestBytes: requestBytes,
|
||||||
|
|||||||
+33
-33
@@ -1,10 +1,10 @@
|
|||||||
// Package rproxy is the reverse proxy in front of one app that asks discriminator
|
// Package rproxy is the reverse proxy in front of one app that asks servewaf
|
||||||
// about each request. It passes a request discriminator allows to the app and the
|
// about each request. It passes a request servewaf allows to the app and the
|
||||||
// app's answer back, within the size and time limits. Any other answer from
|
// app's answer back, within the size and time limits. Any other answer from
|
||||||
// discriminator goes to the visitor as discriminator gave it. SWRP_DEFAULT_DISPOSITION
|
// servewaf goes to the visitor as servewaf gave it. SWRP_DEFAULT_DISPOSITION
|
||||||
// decides only when discriminator cannot be connected to, does not answer at all, or
|
// decides only when servewaf cannot be connected to, does not answer at all, or
|
||||||
// does not answer within SWRP_TIMEOUT_MS. rproxy itself refuses a request with a
|
// does not answer within SWRP_TIMEOUT_MS. rproxy itself refuses a request with a
|
||||||
// method it does not pass on, or one it cannot put to discriminator as the visitor
|
// method it does not pass on, or one it cannot put to servewaf as the visitor
|
||||||
// sent it. It keeps nothing between requests and never touches disk. "rproxy" in
|
// sent it. It keeps nothing between requests and never touches disk. "rproxy" in
|
||||||
// SPEC.md says what it does.
|
// SPEC.md says what it does.
|
||||||
package rproxy
|
package rproxy
|
||||||
@@ -22,10 +22,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// How rproxy keeps connections to the app and to discriminator open between
|
// How rproxy keeps connections to the app and to servewaf open between
|
||||||
// requests.
|
// requests.
|
||||||
const (
|
const (
|
||||||
idleConns = 100
|
idleConns = 100
|
||||||
@@ -45,9 +45,9 @@ const (
|
|||||||
idleTimeout = 120 * time.Second
|
idleTimeout = 120 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminPrefix starts the path of every request for sww itself, which
|
// adminPrefix starts the path of every request for smallwebwaf itself, which
|
||||||
// never reaches the app.
|
// never reaches the app.
|
||||||
const adminPrefix = "/_sww/"
|
const adminPrefix = "/_smallwebwaf/"
|
||||||
|
|
||||||
// allowedMethods lists the methods rproxy passes on, as an Allow header gives
|
// allowedMethods lists the methods rproxy passes on, as an Allow header gives
|
||||||
// them. It answers any other method 405 itself.
|
// them. It answers any other method 405 itself.
|
||||||
@@ -62,14 +62,14 @@ type Params struct {
|
|||||||
ProcessLog *slog.Logger
|
ProcessLog *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the server `sww rproxy` runs.
|
// Server is the server `smallwebwaf rproxy` runs.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
*http.Server
|
*http.Server
|
||||||
|
|
||||||
h *handler
|
h *handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns the server `sww rproxy` runs. Go's server itself refuses
|
// New returns the server `smallwebwaf rproxy` runs. Go's server itself refuses
|
||||||
// a request line and headers over requestHeaderMaxBytes, with 431, closes a
|
// a request line and headers over requestHeaderMaxBytes, with 431, closes a
|
||||||
// connection idle for idleTimeout, and applies SWRP_MAX_REQUEST_TIME_MS while
|
// connection idle for idleTimeout, and applies SWRP_MAX_REQUEST_TIME_MS while
|
||||||
// the headers arrive. The handler applies the time and size limits from then
|
// the headers arrive. The handler applies the time and size limits from then
|
||||||
@@ -78,14 +78,14 @@ func New(params Params) *Server {
|
|||||||
backend := params.Config.WAFBackend
|
backend := params.Config.WAFBackend
|
||||||
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
|
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
|
||||||
h := &handler{
|
h := &handler{
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
requestLog: params.RequestLog,
|
requestLog: params.RequestLog,
|
||||||
processLog: params.ProcessLog,
|
processLog: params.ProcessLog,
|
||||||
errorLog: errorLog,
|
errorLog: errorLog,
|
||||||
transport: newTransport(),
|
transport: newTransport(),
|
||||||
discriminatorTransport: newDiscriminatorTransport(),
|
serveWAFTransport: newServeWAFTransport(),
|
||||||
reportURL: (&url.URL{
|
reportURL: (&url.URL{
|
||||||
Scheme: backend.Scheme, Host: backend.Host, Path: discriminator.ReportPath,
|
Scheme: backend.Scheme, Host: backend.Host, Path: servewaf.ReportPath,
|
||||||
}).String(),
|
}).String(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,10 +120,10 @@ type handler struct {
|
|||||||
requestLog io.Writer
|
requestLog io.Writer
|
||||||
processLog *slog.Logger
|
processLog *slog.Logger
|
||||||
errorLog *log.Logger
|
errorLog *log.Logger
|
||||||
// transport carries requests to the app, and discriminatorTransport questions and
|
// transport carries requests to the app, and serveWAFTransport questions and
|
||||||
// reports to discriminator.
|
// reports to servewaf.
|
||||||
transport http.RoundTripper
|
transport http.RoundTripper
|
||||||
discriminatorTransport http.RoundTripper
|
serveWAFTransport http.RoundTripper
|
||||||
// reportURL is where the reports go.
|
// reportURL is where the reports go.
|
||||||
reportURL string
|
reportURL string
|
||||||
// reports waits for the reports being sent. reportsMu guards starting one
|
// reports waits for the reports being sent. reportsMu guards starting one
|
||||||
@@ -145,11 +145,11 @@ func newTransport() *http.Transport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newDiscriminatorTransport returns what carries questions and reports to
|
// newServeWAFTransport returns what carries questions and reports to servewaf.
|
||||||
// discriminator. It speaks HTTP/1.1 only. Go's HTTP/2 client refuses to send
|
// It speaks HTTP/1.1 only. Go's HTTP/2 client refuses to send a visitor's Upgrade
|
||||||
// a visitor's Upgrade header, or a Connection header other than close or
|
// header, or a Connection header other than close or keep-alive, and that would
|
||||||
// keep-alive, and that would read as discriminator giving no answer.
|
// read as servewaf giving no answer.
|
||||||
func newDiscriminatorTransport() *http.Transport {
|
func newServeWAFTransport() *http.Transport {
|
||||||
transport := newTransport()
|
transport := newTransport()
|
||||||
transport.Protocols = new(http.Protocols)
|
transport.Protocols = new(http.Protocols)
|
||||||
transport.Protocols.SetHTTP1(true)
|
transport.Protocols.SetHTTP1(true)
|
||||||
@@ -158,15 +158,15 @@ func newDiscriminatorTransport() *http.Transport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ServeHTTP handles one request: it answers the health check itself, refuses a
|
// ServeHTTP handles one request: it answers the health check itself, refuses a
|
||||||
// request with a method it does not pass on or one it cannot put to discriminator as
|
// request with a method it does not pass on or one it cannot put to servewaf as
|
||||||
// the visitor sent it, and for any other request reads the first part of the
|
// the visitor sent it, and for any other request reads the first part of the
|
||||||
// body, asks discriminator, and does what the answer says. Once the answer has ended,
|
// body, asks servewaf, and does what the answer says. Once the answer has ended,
|
||||||
// it writes the request's log line and sends its report.
|
// it writes the request's log line and sends its report.
|
||||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
rq := h.newRequest(w, r)
|
rq := h.newRequest(w, r)
|
||||||
defer rq.finish(r.Context())
|
defer rq.finish(r.Context())
|
||||||
|
|
||||||
if r.Method == http.MethodGet && r.URL.Path == discriminator.HealthPath {
|
if r.Method == http.MethodGet && r.URL.Path == servewaf.HealthPath {
|
||||||
rq.answerHealth()
|
rq.answerHealth()
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -199,9 +199,9 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
rq.noteAnswer(answer)
|
rq.noteAnswer(answer)
|
||||||
|
|
||||||
switch rq.disposition {
|
switch rq.disposition {
|
||||||
case discriminator.DispositionAllow:
|
case servewaf.DispositionAllow:
|
||||||
rq.passToApp(r.Context())
|
rq.passToApp(r.Context())
|
||||||
case discriminator.DispositionClose:
|
case servewaf.DispositionClose:
|
||||||
// This panic has Go's server close the connection without an answer,
|
// This panic has Go's server close the connection without an answer,
|
||||||
// and log nothing; the deferred finish still runs.
|
// and log nothing; the deferred finish still runs.
|
||||||
panic(http.ErrAbortHandler)
|
panic(http.ErrAbortHandler)
|
||||||
@@ -211,7 +211,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// passToApp passes the request on to the app, unless a limit or its path keeps
|
// passToApp passes the request on to the app, unless a limit or its path keeps
|
||||||
// it from the app. discriminator answers a path under adminPrefix itself, so such a
|
// it from the app. servewaf answers a path under adminPrefix itself, so such a
|
||||||
// request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the
|
// request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the
|
||||||
// request's own context.
|
// request's own context.
|
||||||
func (rq *request) passToApp(ctx context.Context) {
|
func (rq *request) passToApp(ctx context.Context) {
|
||||||
|
|||||||
+106
-110
@@ -19,9 +19,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -54,27 +54,27 @@ const (
|
|||||||
|
|
||||||
// The headers the tests set and look at.
|
// The headers the tests set and look at.
|
||||||
const (
|
const (
|
||||||
dispositionHeader = discriminator.DispositionHeader
|
dispositionHeader = servewaf.DispositionHeader
|
||||||
forwardedForHeader = "X-Forwarded-For"
|
forwardedForHeader = "X-Forwarded-For"
|
||||||
forwardedProtoHeader = "X-Forwarded-Proto"
|
forwardedProtoHeader = "X-Forwarded-Proto"
|
||||||
requestIDHeader = "X-Request-ID"
|
requestIDHeader = "X-Request-ID"
|
||||||
asnHeader = "X-Client-Asn"
|
asnHeader = "X-Client-Asn"
|
||||||
countryHeader = "X-Client-Country"
|
countryHeader = "X-Client-Country"
|
||||||
contentLengthHeader = "Content-Length"
|
contentLengthHeader = "Content-Length"
|
||||||
// fromDiscriminatorHeader is a header the stand-in for discriminator adds to a deny.
|
// fromServeWAFHeader is a header the stand-in for servewaf adds to a deny.
|
||||||
fromDiscriminatorHeader = "X-From-Discriminator"
|
fromServeWAFHeader = "X-From-Servewaf"
|
||||||
// addedHeaderValue is the value of a header a test adds to an answer, to see it
|
// addedHeaderValue is the value of a header a test adds to an answer, to see it
|
||||||
// reach the visitor.
|
// reach the visitor.
|
||||||
addedHeaderValue = "yes"
|
addedHeaderValue = "yes"
|
||||||
)
|
)
|
||||||
|
|
||||||
// What the stand-in for discriminator answers.
|
// What the stand-in for servewaf answers.
|
||||||
const (
|
const (
|
||||||
dispositionAllow = discriminator.DispositionAllow
|
dispositionAllow = servewaf.DispositionAllow
|
||||||
dispositionDeny = discriminator.DispositionDeny
|
dispositionDeny = servewaf.DispositionDeny
|
||||||
dispositionClose = discriminator.DispositionClose
|
dispositionClose = servewaf.DispositionClose
|
||||||
// deniedBody is the body of a deny.
|
// deniedBody is the body of a deny.
|
||||||
deniedBody = "refused by discriminator"
|
deniedBody = "refused by servewaf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// output collects what rproxy writes on stdout.
|
// output collects what rproxy writes on stdout.
|
||||||
@@ -158,7 +158,7 @@ func (l heldLog) Write(p []byte) (int, error) {
|
|||||||
return len(p), nil
|
return len(p), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// question is what the stand-in for discriminator received as a question.
|
// question is what the stand-in for servewaf received as a question.
|
||||||
type question struct {
|
type question struct {
|
||||||
method, target, host string
|
method, target, host string
|
||||||
header http.Header
|
header http.Header
|
||||||
@@ -167,26 +167,26 @@ type question struct {
|
|||||||
transferEncoding []string
|
transferEncoding []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discriminator is a stand-in for discriminator that answers each question as
|
// serveWAF is a stand-in for servewaf that answers each question as answer
|
||||||
// answer does, and keeps the questions and the reports it gets.
|
// does, and keeps the questions and the reports it gets.
|
||||||
type Discriminator struct {
|
type serveWAF struct {
|
||||||
*httptest.Server
|
*httptest.Server
|
||||||
|
|
||||||
questions chan question
|
questions chan question
|
||||||
reports chan discriminator.Report
|
reports chan servewaf.Report
|
||||||
}
|
}
|
||||||
|
|
||||||
// startDiscriminator starts a stand-in for discriminator.
|
// startServeWAF starts a stand-in for servewaf.
|
||||||
func startDiscriminator(t *testing.T, answer http.HandlerFunc) *Discriminator {
|
func startServeWAF(t *testing.T, answer http.HandlerFunc) *serveWAF {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
s := &Discriminator{
|
s := &serveWAF{
|
||||||
questions: make(chan question, 10),
|
questions: make(chan question, 10),
|
||||||
reports: make(chan discriminator.Report, 10),
|
reports: make(chan servewaf.Report, 10),
|
||||||
}
|
}
|
||||||
s.Server = httptest.NewServer(http.HandlerFunc(
|
s.Server = httptest.NewServer(http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Header.Get(discriminator.ClientHeader) == "" {
|
if r.Header.Get(servewaf.ClientHeader) == "" {
|
||||||
s.takeReport(t, w, r)
|
s.takeReport(t, w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -209,17 +209,14 @@ func startDiscriminator(t *testing.T, answer http.HandlerFunc) *Discriminator {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// takeReport keeps a report and answers it as discriminator does.
|
// takeReport keeps a report and answers it as servewaf does.
|
||||||
func (s *Discriminator) takeReport(
|
func (s *serveWAF) takeReport(t *testing.T, w http.ResponseWriter, r *http.Request) {
|
||||||
t *testing.T, w http.ResponseWriter, r *http.Request,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
var report discriminator.Report
|
var report servewaf.Report
|
||||||
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&report)
|
err := json.NewDecoder(r.Body).Decode(&report)
|
||||||
if err != nil || r.Method != http.MethodPost ||
|
if err != nil || r.Method != http.MethodPost || r.URL.Path != servewaf.ReportPath {
|
||||||
r.URL.Path != discriminator.ReportPath {
|
|
||||||
t.Errorf("%s %s is no report: %v", r.Method, r.URL.Path, err)
|
t.Errorf("%s %s is no report: %v", r.Method, r.URL.Path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +226,7 @@ func (s *Discriminator) takeReport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wantNoReport checks that no report comes for a moment.
|
// wantNoReport checks that no report comes for a moment.
|
||||||
func (s *Discriminator) wantNoReport(t *testing.T) {
|
func (s *serveWAF) wantNoReport(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@@ -240,7 +237,7 @@ func (s *Discriminator) wantNoReport(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// report waits for a report.
|
// report waits for a report.
|
||||||
func (s *Discriminator) report(t *testing.T) discriminator.Report {
|
func (s *serveWAF) report(t *testing.T) servewaf.Report {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@@ -249,12 +246,12 @@ func (s *Discriminator) report(t *testing.T) discriminator.Report {
|
|||||||
case <-time.After(waitLimit):
|
case <-time.After(waitLimit):
|
||||||
t.Fatal("no report")
|
t.Fatal("no report")
|
||||||
|
|
||||||
return discriminator.Report{}
|
return servewaf.Report{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// question waits for a question.
|
// question waits for a question.
|
||||||
func (s *Discriminator) question(t *testing.T) question {
|
func (s *serveWAF) question(t *testing.T) question {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@@ -267,17 +264,16 @@ func (s *Discriminator) question(t *testing.T) question {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startDiscriminatorAnsweringReports starts a stand-in for discriminator that
|
// startServeWAFAnsweringReports starts a stand-in for servewaf that answers each
|
||||||
// answers each question as answer does and each report as report does, and
|
// question as answer does and each report as report does, and returns its URL.
|
||||||
// returns its URL.
|
func startServeWAFAnsweringReports(
|
||||||
func startDiscriminatorAnsweringReports(
|
|
||||||
t *testing.T, answer, report http.HandlerFunc,
|
t *testing.T, answer, report http.HandlerFunc,
|
||||||
) string {
|
) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
backend := httptest.NewServer(http.HandlerFunc(
|
backend := httptest.NewServer(http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Header.Get(discriminator.ClientHeader) != "" {
|
if r.Header.Get(servewaf.ClientHeader) != "" {
|
||||||
answer(w, r)
|
answer(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -299,7 +295,7 @@ func allow(w http.ResponseWriter, _ *http.Request) {
|
|||||||
// deny answers a question with deny, status 403 and a body of its own.
|
// deny answers a question with deny, status 403 and a body of its own.
|
||||||
func deny(w http.ResponseWriter, _ *http.Request) {
|
func deny(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set(dispositionHeader, dispositionDeny)
|
w.Header().Set(dispositionHeader, dispositionDeny)
|
||||||
w.Header().Set(fromDiscriminatorHeader, addedHeaderValue)
|
w.Header().Set(fromServeWAFHeader, addedHeaderValue)
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
_, _ = io.WriteString(w, deniedBody)
|
_, _ = io.WriteString(w, deniedBody)
|
||||||
}
|
}
|
||||||
@@ -320,7 +316,7 @@ func greet(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = io.WriteString(w, greeting)
|
_, _ = io.WriteString(w, greeting)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startRProxy starts rproxy with discriminator at backend and the app at appURL, and
|
// startRProxy starts rproxy with servewaf at backend and the app at appURL, and
|
||||||
// the settings in env on top of the defaults. It returns where rproxy listens
|
// the settings in env on top of the defaults. It returns where rproxy listens
|
||||||
// and what it writes.
|
// and what it writes.
|
||||||
func startRProxy(
|
func startRProxy(
|
||||||
@@ -502,7 +498,7 @@ func readStatus(t *testing.T, conn net.Conn) int {
|
|||||||
func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
|
func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, func(w http.ResponseWriter, r *http.Request) {
|
standIn := startServeWAF(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set(asnHeader, "AS64496")
|
w.Header().Set(asnHeader, "AS64496")
|
||||||
w.Header().Set(countryHeader, "DE")
|
w.Header().Set(countryHeader, "DE")
|
||||||
allow(w, r)
|
allow(w, r)
|
||||||
@@ -561,7 +557,7 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
|
|||||||
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
|
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
|
||||||
}
|
}
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
seen := make(chan http.Header, 1)
|
seen := make(chan http.Header, 1)
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
seen <- r.Header.Clone()
|
seen <- r.Header.Clone()
|
||||||
@@ -606,7 +602,7 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
|
|||||||
func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
|
func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
seen := make(chan http.Header, 1)
|
seen := make(chan http.Header, 1)
|
||||||
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
seen <- r.Header.Clone()
|
seen <- r.Header.Clone()
|
||||||
@@ -645,7 +641,7 @@ func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
|
|||||||
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
|
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header()["Content-Type"] = nil
|
w.Header()["Content-Type"] = nil
|
||||||
_, _ = io.WriteString(w, "<html>")
|
_, _ = io.WriteString(w, "<html>")
|
||||||
@@ -667,19 +663,19 @@ func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRemovesTheVisitorsLookupHeadersWhenDiscriminatorGivesNone(t *testing.T) {
|
func TestRemovesTheVisitorsLookupHeadersWhenServeWAFGivesNone(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
backend func(t *testing.T) string
|
backend func(t *testing.T) string
|
||||||
}{
|
}{
|
||||||
{"discriminator allows", func(t *testing.T) string {
|
{"servewaf allows", func(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return startDiscriminator(t, allow).URL
|
return startServeWAF(t, allow).URL
|
||||||
}},
|
}},
|
||||||
{"discriminator is down", func(*testing.T) string { return nowhere }},
|
{"servewaf is down", func(*testing.T) string { return nowhere }},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -705,10 +701,10 @@ func TestRemovesTheVisitorsLookupHeadersWhenDiscriminatorGivesNone(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDenyGivesTheVisitorDiscriminatorsAnswer(t *testing.T) {
|
func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, deny)
|
standIn := startServeWAF(t, deny)
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
t.Error("the app was asked")
|
t.Error("the app was asked")
|
||||||
})
|
})
|
||||||
@@ -716,8 +712,8 @@ func TestDenyGivesTheVisitorDiscriminatorsAnswer(t *testing.T) {
|
|||||||
|
|
||||||
got := get(t, addr, "/")
|
got := get(t, addr, "/")
|
||||||
if got.status != http.StatusForbidden || got.body != deniedBody ||
|
if got.status != http.StatusForbidden || got.body != deniedBody ||
|
||||||
got.header.Get(fromDiscriminatorHeader) != addedHeaderValue {
|
got.header.Get(fromServeWAFHeader) != addedHeaderValue {
|
||||||
t.Errorf("answered %+v, want discriminator's answer", got)
|
t.Errorf("answered %+v, want servewaf's answer", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(got.header.Values(dispositionHeader)) != 0 {
|
if len(got.header.Values(dispositionHeader)) != 0 {
|
||||||
@@ -733,21 +729,21 @@ func TestDenyGivesTheVisitorDiscriminatorsAnswer(t *testing.T) {
|
|||||||
|
|
||||||
line := out.requestLine(t)
|
line := out.requestLine(t)
|
||||||
if line["disposition"] != dispositionDeny || line["default_disposition"] != nil {
|
if line["disposition"] != dispositionDeny || line["default_disposition"] != nil {
|
||||||
t.Errorf("line %v, want discriminator's deny", line)
|
t.Errorf("line %v, want servewaf's deny", line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestADeniedHeadKeepsDiscriminatorsContentLength(t *testing.T) {
|
func TestADeniedHeadKeepsServeWAFsContentLength(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, deny)
|
standIn := startServeWAF(t, deny)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody))
|
got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody))
|
||||||
if got.status != http.StatusForbidden ||
|
if got.status != http.StatusForbidden ||
|
||||||
got.header.Get(contentLengthHeader) != strconv.Itoa(len(deniedBody)) {
|
got.header.Get(contentLengthHeader) != strconv.Itoa(len(deniedBody)) {
|
||||||
t.Errorf("answered %+v, want discriminator's 403 and its length", got)
|
t.Errorf("answered %+v, want servewaf's 403 and its length", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,13 +758,13 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"a deny", "GET /" + host, deniedBody, http.StatusForbidden},
|
{"a deny", "GET /" + host, deniedBody, http.StatusForbidden},
|
||||||
{"rproxy's own answer", "GET x:admin" + host, "Bad Request\n", http.StatusBadRequest},
|
{"rproxy's own answer", "GET x:admin" + host, "Bad Request\n", http.StatusBadRequest},
|
||||||
{"the health check", "GET " + discriminator.HealthPath + host, "ok\n", http.StatusOK},
|
{"the health check", "GET " + servewaf.HealthPath + host, "ok\n", http.StatusOK},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
log := heldLog{release: make(chan struct{})}
|
log := heldLog{release: make(chan struct{})}
|
||||||
standIn := startDiscriminator(t, deny)
|
standIn := startServeWAF(t, deny)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startServerLogging(t, standIn.URL, app.URL, nil, log, io.Discard)
|
addr, _ := startServerLogging(t, standIn.URL, app.URL, nil, log, io.Discard)
|
||||||
|
|
||||||
@@ -798,7 +794,7 @@ func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) {
|
|||||||
func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
|
func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
@@ -821,7 +817,7 @@ func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
|
|||||||
func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set(dispositionHeader, dispositionClose)
|
w.Header().Set(dispositionHeader, dispositionClose)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
@@ -853,7 +849,7 @@ func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
|||||||
func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
|
func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
backend := startDiscriminatorAnsweringReports(t,
|
backend := startServeWAFAnsweringReports(t,
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set(dispositionHeader, dispositionClose)
|
w.Header().Set(dispositionHeader, dispositionClose)
|
||||||
},
|
},
|
||||||
@@ -878,17 +874,17 @@ func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// answeringWith returns what starts a stand-in for discriminator that answers each
|
// answeringWith returns what starts a stand-in for servewaf that answers each
|
||||||
// question as answer does, and returns its URL.
|
// question as answer does, and returns its URL.
|
||||||
func answeringWith(answer http.HandlerFunc) func(t *testing.T) string {
|
func answeringWith(answer http.HandlerFunc) func(t *testing.T) string {
|
||||||
return func(t *testing.T) string {
|
return func(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return startDiscriminator(t, answer).URL
|
return startServeWAF(t, answer).URL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// noAnswers are the ways discriminator can leave the decision to the default.
|
// noAnswers are the ways servewaf can leave the decision to the default.
|
||||||
func noAnswers() map[string]func(t *testing.T) string {
|
func noAnswers() map[string]func(t *testing.T) string {
|
||||||
return map[string]func(t *testing.T) string{
|
return map[string]func(t *testing.T) string{
|
||||||
"no connection": func(*testing.T) string { return nowhere },
|
"no connection": func(*testing.T) string { return nowhere },
|
||||||
@@ -920,7 +916,7 @@ func noAnswers() map[string]func(t *testing.T) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.T) {
|
func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -929,7 +925,7 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
|
|||||||
}{
|
}{
|
||||||
{"a refusal", dispositionDeny, deniedBody, http.StatusForbidden},
|
{"a refusal", dispositionDeny, deniedBody, http.StatusForbidden},
|
||||||
{"a refusal, 429", dispositionDeny, deniedBody, http.StatusTooManyRequests},
|
{"a refusal, 429", dispositionDeny, deniedBody, http.StatusTooManyRequests},
|
||||||
{"discriminator's 400", "", "refused\n", http.StatusBadRequest},
|
{"servewaf's 400", "", "refused\n", http.StatusBadRequest},
|
||||||
{"a 500", "", "broken\n", http.StatusInternalServerError},
|
{"a 500", "", "broken\n", http.StatusInternalServerError},
|
||||||
{"a 503 denied", dispositionDeny, "busy\n", http.StatusServiceUnavailable},
|
{"a 503 denied", dispositionDeny, "busy\n", http.StatusServiceUnavailable},
|
||||||
{"no header", "", "who knows\n", http.StatusOK},
|
{"no header", "", "who knows\n", http.StatusOK},
|
||||||
@@ -939,12 +935,12 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
|
|||||||
t.Run(tc.name+" with the default "+setting, func(t *testing.T) {
|
t.Run(tc.name+" with the default "+setting, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
if tc.disposition != "" {
|
if tc.disposition != "" {
|
||||||
w.Header().Set(dispositionHeader, tc.disposition)
|
w.Header().Set(dispositionHeader, tc.disposition)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set(fromDiscriminatorHeader, addedHeaderValue)
|
w.Header().Set(fromServeWAFHeader, addedHeaderValue)
|
||||||
w.WriteHeader(tc.status)
|
w.WriteHeader(tc.status)
|
||||||
_, _ = io.WriteString(w, tc.body)
|
_, _ = io.WriteString(w, tc.body)
|
||||||
})
|
})
|
||||||
@@ -957,8 +953,8 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
|
|||||||
|
|
||||||
got := get(t, addr, "/")
|
got := get(t, addr, "/")
|
||||||
if got.status != tc.status || got.body != tc.body ||
|
if got.status != tc.status || got.body != tc.body ||
|
||||||
got.header.Get(fromDiscriminatorHeader) != addedHeaderValue {
|
got.header.Get(fromServeWAFHeader) != addedHeaderValue {
|
||||||
t.Errorf("answered %+v, want discriminator's answer", got)
|
t.Errorf("answered %+v, want servewaf's answer", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(got.header.Values(dispositionHeader)) != 0 {
|
if len(got.header.Values(dispositionHeader)) != 0 {
|
||||||
@@ -970,8 +966,8 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
|
|||||||
t.Errorf("line %v, want status %d and no default", line, tc.status)
|
t.Errorf("line %v, want status %d and no default", line, tc.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if out.has("discriminator gave no answer") {
|
if out.has("servewaf gave no answer") {
|
||||||
t.Error("rproxy warned that discriminator gave no answer")
|
t.Error("rproxy warned that servewaf gave no answer")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -981,14 +977,14 @@ func TestAnAnswerOtherThanAllowGoesToTheVisitorAsDiscriminatorGaveIt(t *testing.
|
|||||||
func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
|
func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
http.Error(w, "refused", http.StatusBadRequest)
|
http.Error(w, "refused", http.StatusBadRequest)
|
||||||
})
|
})
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
if got := get(t, addr, "/"); got.status != http.StatusBadRequest {
|
if got := get(t, addr, "/"); got.status != http.StatusBadRequest {
|
||||||
t.Errorf("answered %+v, want discriminator's 400", got)
|
t.Errorf("answered %+v, want servewaf's 400", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
if line := out.requestLine(t); line["disposition"] != nil {
|
if line := out.requestLine(t); line["disposition"] != nil {
|
||||||
@@ -1001,7 +997,7 @@ func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
|
|||||||
func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) {
|
func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header()["Content-Type"] = nil
|
w.Header()["Content-Type"] = nil
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
_, _ = io.WriteString(w, "<html>broken")
|
_, _ = io.WriteString(w, "<html>broken")
|
||||||
@@ -1019,7 +1015,7 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
|
|||||||
|
|
||||||
const size = 1 << 20
|
const size = 1 << 20
|
||||||
|
|
||||||
standIn := startDiscriminator(t, func(w http.ResponseWriter, _ *http.Request) {
|
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
_, _ = w.Write(make([]byte, size))
|
_, _ = w.Write(make([]byte, size))
|
||||||
})
|
})
|
||||||
@@ -1027,15 +1023,15 @@ func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
|
|||||||
|
|
||||||
got := get(t, addr, "/")
|
got := get(t, addr, "/")
|
||||||
if got.status != http.StatusInternalServerError || len(got.body) != size {
|
if got.status != http.StatusInternalServerError || len(got.body) != size {
|
||||||
t.Errorf("answered %d with %d bytes, want discriminator's 500 with %d",
|
t.Errorf("answered %d with %d bytes, want servewaf's 500 with %d",
|
||||||
got.status, len(got.body), size)
|
got.status, len(got.body), size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startDiscriminatorWriting starts a stand-in for discriminator that reads
|
// startServeWAFWriting starts a stand-in for servewaf that reads each question and
|
||||||
// each question and answers it with text, as it is, and returns its URL. It
|
// answers it with text, as it is, and returns its URL. It can send what Go's
|
||||||
// can send what Go's server would not.
|
// server would not.
|
||||||
func startDiscriminatorWriting(t *testing.T, text string) string {
|
func startServeWAFWriting(t *testing.T, text string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
||||||
@@ -1105,7 +1101,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
|||||||
"a status below 100": func(t *testing.T) string {
|
"a status below 100": func(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return startDiscriminatorWriting(t, "HTTP/1.1 099 Odd\r\nContent-Length: 0\r\n\r\n")
|
return startServeWAFWriting(t, "HTTP/1.1 099 Odd\r\nContent-Length: 0\r\n\r\n")
|
||||||
},
|
},
|
||||||
"a 101 that holds its connection open": answeringWith(switchAndHold),
|
"a 101 that holds its connection open": answeringWith(switchAndHold),
|
||||||
} {
|
} {
|
||||||
@@ -1130,7 +1126,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
|||||||
t.Errorf("line %v, want status 502 and no disposition", line)
|
t.Errorf("line %v, want status 502 and no disposition", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
out.waitFor(t, "discriminator's answer is dropped")
|
out.waitFor(t, "servewaf's answer is dropped")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1139,7 +1135,7 @@ func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
|||||||
func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
|
func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, overOneMiB(dispositionDeny))
|
standIn := startServeWAF(t, overOneMiB(dispositionDeny))
|
||||||
addr, out := startRProxy(t, standIn.URL, nowhere, nil)
|
addr, out := startRProxy(t, standIn.URL, nowhere, nil)
|
||||||
|
|
||||||
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
|
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
|
||||||
@@ -1196,14 +1192,14 @@ func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAVisitorThatGoesAwayWhileDiscriminatorIsAskedGetsNoDefault(t *testing.T) {
|
func TestAVisitorThatGoesAwayWhileServeWAFIsAskedGetsNoDefault(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, disposition := range []string{dispositionAllow, dispositionDeny} {
|
for _, disposition := range []string{dispositionAllow, dispositionDeny} {
|
||||||
t.Run("with the default "+disposition, func(t *testing.T) {
|
t.Run("with the default "+disposition, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, func(_ http.ResponseWriter, r *http.Request) {
|
standIn := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||||
<-r.Context().Done()
|
<-r.Context().Done()
|
||||||
})
|
})
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
@@ -1226,8 +1222,8 @@ func TestAVisitorThatGoesAwayWhileDiscriminatorIsAskedGetsNoDefault(t *testing.T
|
|||||||
t.Errorf("line %v, want status 0 and no disposition", line)
|
t.Errorf("line %v, want status 0 and no disposition", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
if out.has("discriminator gave no answer") {
|
if out.has("servewaf gave no answer") {
|
||||||
t.Error("rproxy warned that discriminator gave no answer")
|
t.Error("rproxy warned that servewaf gave no answer")
|
||||||
}
|
}
|
||||||
|
|
||||||
standIn.wantNoReport(t)
|
standIn.wantNoReport(t)
|
||||||
@@ -1242,13 +1238,13 @@ func TestRProxyWarnsWhenTheDefaultDecides(t *testing.T) {
|
|||||||
addr, out := startRProxy(t, nowhere, app.URL, nil)
|
addr, out := startRProxy(t, nowhere, app.URL, nil)
|
||||||
|
|
||||||
wantGreeting(t, get(t, addr, "/"))
|
wantGreeting(t, get(t, addr, "/"))
|
||||||
out.waitFor(t, "discriminator gave no answer")
|
out.waitFor(t, "servewaf gave no answer")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
|
func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, func(_ http.ResponseWriter, r *http.Request) {
|
standIn := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) {
|
||||||
<-r.Context().Done()
|
<-r.Context().Done()
|
||||||
})
|
})
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
@@ -1269,35 +1265,35 @@ func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) {
|
|||||||
})
|
})
|
||||||
addr, _ := startRProxy(t, nowhere, app.URL, nil)
|
addr, _ := startRProxy(t, nowhere, app.URL, nil)
|
||||||
|
|
||||||
got := get(t, addr, "/_sww/metrics")
|
got := get(t, addr, "/_smallwebwaf/metrics")
|
||||||
if got.status != http.StatusNotFound {
|
if got.status != http.StatusNotFound {
|
||||||
t.Errorf("answered %+v, want 404", got)
|
t.Errorf("answered %+v, want 404", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHealthCheckIsAnsweredWhileDiscriminatorIsDown(t *testing.T) {
|
func TestHealthCheckIsAnsweredWhileServeWAFIsDown(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, nowhere, app.URL, nil)
|
addr, out := startRProxy(t, nowhere, app.URL, nil)
|
||||||
|
|
||||||
got := get(t, addr, discriminator.HealthPath)
|
got := get(t, addr, servewaf.HealthPath)
|
||||||
if got.status != http.StatusOK || got.body != "ok\n" {
|
if got.status != http.StatusOK || got.body != "ok\n" {
|
||||||
t.Errorf("answered %+v, want ok", got)
|
t.Errorf("answered %+v, want ok", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
line := out.requestLine(t)
|
line := out.requestLine(t)
|
||||||
if line["path"] != discriminator.HealthPath || line["disposition"] != nil {
|
if line["path"] != servewaf.HealthPath || line["disposition"] != nil {
|
||||||
t.Errorf("line %v, want one with no disposition", line)
|
t.Errorf("line %v, want one with no disposition", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
// With discriminator up too, it is not asked.
|
// With servewaf up too, it is not asked.
|
||||||
addr, _ = startRProxy(t, standIn.URL, app.URL, nil)
|
addr, _ = startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
get(t, addr, discriminator.HealthPath)
|
get(t, addr, servewaf.HealthPath)
|
||||||
|
|
||||||
if len(standIn.questions) != 0 {
|
if len(standIn.questions) != 0 {
|
||||||
t.Error("discriminator was asked about the health check")
|
t.Error("servewaf was asked about the health check")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1305,7 +1301,7 @@ func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
backend := startDiscriminatorAnsweringReports(t, deny,
|
backend := startServeWAFAnsweringReports(t, deny,
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
select {
|
select {
|
||||||
case <-release:
|
case <-release:
|
||||||
@@ -1340,16 +1336,16 @@ func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
|
|||||||
_ = res.Body.Close()
|
_ = res.Body.Close()
|
||||||
|
|
||||||
if res.StatusCode != http.StatusForbidden {
|
if res.StatusCode != http.StatusForbidden {
|
||||||
t.Errorf("status %d, want discriminator's 403", res.StatusCode)
|
t.Errorf("status %d, want servewaf's 403", res.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAReportDiscriminatorLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
|
func TestAReportServeWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
backend := startDiscriminatorAnsweringReports(t, allow,
|
backend := startServeWAFAnsweringReports(t, allow,
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
<-release
|
<-release
|
||||||
|
|
||||||
@@ -1369,10 +1365,10 @@ func TestAReportDiscriminatorLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.
|
|||||||
out.waitFor(t, "a report is dropped")
|
out.waitFor(t, "a report is dropped")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAReportDiscriminatorRefusesIsDroppedWithAWarning(t *testing.T) {
|
func TestAReportServeWAFRefusesIsDroppedWithAWarning(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
backend := startDiscriminatorAnsweringReports(t, allow,
|
backend := startServeWAFAnsweringReports(t, allow,
|
||||||
func(w http.ResponseWriter, _ *http.Request) {
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
})
|
})
|
||||||
@@ -1387,7 +1383,7 @@ func TestAReportDiscriminatorRefusesIsDroppedWithAWarning(t *testing.T) {
|
|||||||
func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
|
func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
backend := startDiscriminatorAnsweringReports(t, allow, switchAndHold)
|
backend := startServeWAFAnsweringReports(t, allow, switchAndHold)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
||||||
timeoutSetting: "60000",
|
timeoutSetting: "60000",
|
||||||
@@ -1400,7 +1396,7 @@ func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
|
|||||||
func TestAReportDueAfterWaitForReportsIsDropped(t *testing.T) {
|
func TestAReportDueAfterWaitForReportsIsDropped(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out, server := startServer(t, standIn.URL, app.URL, nil)
|
addr, out, server := startServer(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
@@ -1416,7 +1412,7 @@ func TestReportGivesWhatHappened(t *testing.T) {
|
|||||||
|
|
||||||
const body = "the visitor's body"
|
const body = "the visitor's body"
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
@@ -1446,7 +1442,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
|
|||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
backend := httptest.NewServer(http.HandlerFunc(
|
backend := httptest.NewServer(http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Header.Get(discriminator.ClientHeader) != "" {
|
if r.Header.Get(servewaf.ClientHeader) != "" {
|
||||||
allow(w, r)
|
allow(w, r)
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -1501,7 +1497,7 @@ func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
|
|||||||
func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) {
|
func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, echoAfterUpgrade)
|
app := startApp(t, echoAfterUpgrade)
|
||||||
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
maxRequestTime: "2000",
|
maxRequestTime: "2000",
|
||||||
@@ -1556,7 +1552,7 @@ func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
|
|||||||
|
|
||||||
const goodbye = "sent after the visitor stopped sending\n"
|
const goodbye = "sent after the visitor stopped sending\n"
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
conn, buffered, err := http.NewResponseController(w).Hijack()
|
conn, buffered, err := http.NewResponseController(w).Hijack()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1614,7 +1610,7 @@ func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
|
|||||||
//nolint:paralleltest // see above
|
//nolint:paralleltest // see above
|
||||||
func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) {
|
func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) {
|
||||||
closed := make(chan error, 1)
|
closed := make(chan error, 1)
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
conn, buffered, err := http.NewResponseController(w).Hijack()
|
conn, buffered, err := http.NewResponseController(w).Hijack()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1685,7 +1681,7 @@ func TestEachRequestGetsAFreshID(t *testing.T) {
|
|||||||
|
|
||||||
const fromTraefik = "an-id-from-traefik"
|
const fromTraefik = "an-id-from-traefik"
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
||||||
trustedProxies: localhost + "/32",
|
trustedProxies: localhost + "/32",
|
||||||
@@ -1715,7 +1711,7 @@ func TestLogLineGivesTheRequestAsSent(t *testing.T) {
|
|||||||
body = "the visitor's body"
|
body = "the visitor's body"
|
||||||
)
|
)
|
||||||
|
|
||||||
standIn := startDiscriminator(t, allow)
|
standIn := startServeWAF(t, allow)
|
||||||
app := startApp(t, greet)
|
app := startApp(t, greet)
|
||||||
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,15 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// answerBodyMaxBytes is the most of the body of discriminator's answer that is read.
|
// answerBodyMaxBytes is the most of the body of servewaf's answer that is read.
|
||||||
// It is far above any answer's.
|
// It is far above any answer's.
|
||||||
const answerBodyMaxBytes = 1 << 20
|
const answerBodyMaxBytes = 1 << 20
|
||||||
|
|
||||||
// reportAnswerMaxBytes is the most of the answer to a report that is read,
|
// reportAnswerMaxBytes is the most of the answer to a report that is read,
|
||||||
// which has no body when discriminator takes the report.
|
// which has no body when servewaf takes the report.
|
||||||
const reportAnswerMaxBytes = 4 << 10
|
const reportAnswerMaxBytes = 4 << 10
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -27,22 +27,21 @@ var (
|
|||||||
errReportRefused = errors.New("the report was answered")
|
errReportRefused = errors.New("the report was answered")
|
||||||
)
|
)
|
||||||
|
|
||||||
// discriminatorAnswer is what discriminator answered to a question.
|
// servewafAnswer is what servewaf answered to a question.
|
||||||
type discriminatorAnswer struct {
|
type servewafAnswer struct {
|
||||||
status int
|
status int
|
||||||
header http.Header
|
header http.Header
|
||||||
body []byte
|
body []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// noteAnswer notes what discriminator answered. An answer without a valid disposition
|
// noteAnswer notes what servewaf answered. An answer without a valid disposition
|
||||||
// notes nothing, and no report is due for it: discriminator holds no decision to report
|
// notes nothing, and no report is due for it: servewaf holds no decision to report
|
||||||
// on.
|
// on.
|
||||||
func (rq *request) noteAnswer(answer *discriminatorAnswer) {
|
func (rq *request) noteAnswer(answer *servewafAnswer) {
|
||||||
disposition := answer.header.Get(discriminator.DispositionHeader)
|
disposition := answer.header.Get(servewaf.DispositionHeader)
|
||||||
|
|
||||||
switch disposition {
|
switch disposition {
|
||||||
case discriminator.DispositionAllow, discriminator.DispositionDeny,
|
case servewaf.DispositionAllow, servewaf.DispositionDeny, servewaf.DispositionClose:
|
||||||
discriminator.DispositionClose:
|
|
||||||
rq.disposition = disposition
|
rq.disposition = disposition
|
||||||
rq.answered = true
|
rq.answered = true
|
||||||
rq.asn = answer.header.Get(asnHeader)
|
rq.asn = answer.header.Get(asnHeader)
|
||||||
@@ -55,27 +54,27 @@ func (rq *request) noteAnswer(answer *discriminatorAnswer) {
|
|||||||
func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
||||||
switch {
|
switch {
|
||||||
case ctx.Err() != nil:
|
case ctx.Err() != nil:
|
||||||
// The visitor went away while discriminator was being asked. discriminator has not
|
// The visitor went away while servewaf was being asked. servewaf has not
|
||||||
// failed, and nothing is noted. This panic has Go's server close the
|
// failed, and nothing is noted. This panic has Go's server close the
|
||||||
// connection without an answer, and log nothing; the deferred finish still
|
// connection without an answer, and log nothing; the deferred finish still
|
||||||
// runs.
|
// runs.
|
||||||
panic(http.ErrAbortHandler)
|
panic(http.ErrAbortHandler)
|
||||||
case errors.Is(err, errCannotPassOn):
|
case errors.Is(err, errCannotPassOn):
|
||||||
rq.h.processLog.Warn("discriminator's answer is dropped", "error", err.Error())
|
rq.h.processLog.Warn("servewaf's answer is dropped", "error", err.Error())
|
||||||
rq.answer(http.StatusBadGateway)
|
rq.answer(http.StatusBadGateway)
|
||||||
default:
|
default:
|
||||||
// discriminator cannot be connected to, does not answer at all, or does not answer
|
// servewaf cannot be connected to, does not answer at all, or does not answer
|
||||||
// within SWRP_TIMEOUT_MS. A request that keeps discriminator busy past
|
// within SWRP_TIMEOUT_MS. A request that keeps servewaf busy past
|
||||||
// SWRP_TIMEOUT_MS, like any request while discriminator is overloaded, gets
|
// SWRP_TIMEOUT_MS, like any request while servewaf is overloaded, gets
|
||||||
// SWRP_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
|
// SWRP_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503
|
||||||
// whenever discriminator is slow. Making such requests hard to send is
|
// whenever servewaf is slow. Making such requests hard to send is
|
||||||
// https://git.eeqj.de/sneak/smallwebwaf/issues/182.
|
// https://git.eeqj.de/sneak/smallwebwaf/issues/182.
|
||||||
rq.h.processLog.Warn("discriminator gave no answer", "error", err.Error())
|
rq.h.processLog.Warn("servewaf gave no answer", "error", err.Error())
|
||||||
|
|
||||||
rq.disposition = rq.h.config.DefaultDisposition
|
rq.disposition = rq.h.config.DefaultDisposition
|
||||||
rq.byDefault = true
|
rq.byDefault = true
|
||||||
|
|
||||||
if rq.disposition == discriminator.DispositionAllow {
|
if rq.disposition == servewaf.DispositionAllow {
|
||||||
rq.passToApp(ctx)
|
rq.passToApp(ctx)
|
||||||
} else {
|
} else {
|
||||||
rq.answer(http.StatusServiceUnavailable)
|
rq.answer(http.StatusServiceUnavailable)
|
||||||
@@ -83,10 +82,10 @@ func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ask sends discriminator the question, and returns its answer, which must have come
|
// ask sends servewaf the question, and returns its answer, which must have come
|
||||||
// whole within SWRP_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
|
// whole within SWRP_TIMEOUT_MS. It ends when ctx does, as when the visitor goes
|
||||||
// away.
|
// away.
|
||||||
func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, error) {
|
func (rq *request) ask(ctx context.Context) (*servewafAnswer, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
|
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -95,7 +94,7 @@ func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := rq.h.discriminatorTransport.RoundTrip(question)
|
res, err := rq.h.serveWAFTransport.RoundTrip(question)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("send the question: %w", err)
|
return nil, fmt.Errorf("send the question: %w", err)
|
||||||
}
|
}
|
||||||
@@ -120,17 +119,17 @@ func (rq *request) ask(ctx context.Context) (*discriminatorAnswer, error) {
|
|||||||
return nil, fmt.Errorf("%w: its body is over 1 MiB", errCannotPassOn)
|
return nil, fmt.Errorf("%w: its body is over 1 MiB", errCannotPassOn)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &discriminatorAnswer{res.StatusCode, res.Header, body}, nil
|
return &servewafAnswer{res.StatusCode, res.Header, body}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// canBeAsked reports whether Go's client can send discriminator r as the visitor sent
|
// canBeAsked reports whether Go's client can send servewaf r as the visitor sent
|
||||||
// it. It cannot send these as they came:
|
// it. It cannot send these as they came:
|
||||||
// - a target that does not start with /, or that starts with //;
|
// - a target that does not start with /, or that starts with //;
|
||||||
// - an empty Host, for which it sends discriminator's own address;
|
// - an empty Host, for which it sends servewaf's own address;
|
||||||
// - a Host holding an IPv6 zone, which it removes;
|
// - a Host holding an IPv6 zone, which it removes;
|
||||||
// - more than one User-Agent, of which it sends the first;
|
// - more than one User-Agent, of which it sends the first;
|
||||||
// - an empty User-Agent, which it leaves out, the one case that still goes to
|
// - an empty User-Agent, which it leaves out, the one case that still goes to
|
||||||
// discriminator.
|
// servewaf.
|
||||||
func canBeAsked(r *http.Request) bool {
|
func canBeAsked(r *http.Request) bool {
|
||||||
target, host := r.RequestURI, r.Host
|
target, host := r.RequestURI, r.Host
|
||||||
hostHasZone := strings.HasPrefix(host, "[") && strings.Contains(host, "%")
|
hostHasZone := strings.HasPrefix(host, "[") && strings.Contains(host, "%")
|
||||||
@@ -170,13 +169,13 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
|
|||||||
question.TransferEncoding = in.TransferEncoding
|
question.TransferEncoding = in.TransferEncoding
|
||||||
|
|
||||||
question.Header = in.Header.Clone()
|
question.Header = in.Header.Clone()
|
||||||
question.Header.Set(discriminator.ClientHeader, rq.client.String())
|
question.Header.Set(servewaf.ClientHeader, rq.client.String())
|
||||||
question.Header.Set(discriminator.PeerHeader, rq.peer.String())
|
question.Header.Set(servewaf.PeerHeader, rq.peer.String())
|
||||||
question.Header.Del(discriminator.ContentLengthHeader)
|
question.Header.Del(servewaf.ContentLengthHeader)
|
||||||
|
|
||||||
announced := in.Header.Get("Content-Length")
|
announced := in.Header.Get("Content-Length")
|
||||||
if announced != "" {
|
if announced != "" {
|
||||||
question.Header.Set(discriminator.ContentLengthHeader, announced)
|
question.Header.Set(servewaf.ContentLengthHeader, announced)
|
||||||
}
|
}
|
||||||
|
|
||||||
question.Header.Set(requestIDHeader, rq.id)
|
question.Header.Set(requestIDHeader, rq.id)
|
||||||
@@ -191,13 +190,13 @@ func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
|
|||||||
return question, nil
|
return question, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// answerWith gives the visitor discriminator's answer, less its disposition.
|
// answerWith gives the visitor servewaf's answer, less its disposition.
|
||||||
func (rq *request) answerWith(answer *discriminatorAnswer) {
|
func (rq *request) answerWith(answer *servewafAnswer) {
|
||||||
rq.prepareOwnAnswer(rq.requestDeadline())
|
rq.prepareOwnAnswer(rq.requestDeadline())
|
||||||
|
|
||||||
header := rq.out.Header()
|
header := rq.out.Header()
|
||||||
maps.Copy(header, answer.header)
|
maps.Copy(header, answer.header)
|
||||||
header.Del(discriminator.DispositionHeader)
|
header.Del(servewaf.DispositionHeader)
|
||||||
|
|
||||||
// Go's server adds a Content-Type it guesses from the body to a response whose
|
// Go's server adds a Content-Type it guesses from the body to a response whose
|
||||||
// headers have no Content-Type key. The key with no value sends none.
|
// headers have no Content-Type key. The key with no value sends none.
|
||||||
@@ -208,10 +207,10 @@ func (rq *request) answerWith(answer *discriminatorAnswer) {
|
|||||||
rq.send(answer.status, answer.body)
|
rq.send(answer.status, answer.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendReport sends discriminator report on a goroutine of its own, so that the
|
// sendReport sends servewaf report on a goroutine of its own, so that the
|
||||||
// visitor does not wait for it. The report goes on after ctx, the request's own
|
// visitor does not wait for it. The report goes on after ctx, the request's own
|
||||||
// context, has ended.
|
// context, has ended.
|
||||||
func (h *handler) sendReport(ctx context.Context, report *discriminator.Report) {
|
func (h *handler) sendReport(ctx context.Context, report *servewaf.Report) {
|
||||||
h.reportsMu.Lock()
|
h.reportsMu.Lock()
|
||||||
defer h.reportsMu.Unlock()
|
defer h.reportsMu.Unlock()
|
||||||
|
|
||||||
@@ -227,9 +226,9 @@ func (h *handler) sendReport(ctx context.Context, report *discriminator.Report)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// postReport posts report to discriminator, and waits for its answer for up to
|
// postReport posts report to servewaf, and waits for its answer for up to
|
||||||
// SWRP_TIMEOUT_MS.
|
// SWRP_TIMEOUT_MS.
|
||||||
func (h *handler) postReport(ctx context.Context, report *discriminator.Report) error {
|
func (h *handler) postReport(ctx context.Context, report *servewaf.Report) error {
|
||||||
body, err := json.Marshal(report)
|
body, err := json.Marshal(report)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encode the report: %w", err)
|
return fmt.Errorf("encode the report: %w", err)
|
||||||
@@ -246,7 +245,7 @@ func (h *handler) postReport(ctx context.Context, report *discriminator.Report)
|
|||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
res, err := h.discriminatorTransport.RoundTrip(req)
|
res, err := h.serveWAFTransport.RoundTrip(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("send the report: %w", err)
|
return fmt.Errorf("send the report: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package rules reads the rule files: the plain text files in
|
// Package rules reads the rule files: the plain text files in
|
||||||
// SWW_RULES_DIR, one rule to a line, that each request is checked
|
// SWWAF_RULES_DIR, one rule to a line, that each request is checked
|
||||||
// against, as the "Rule files" section of SPEC.md describes. They are read
|
// against, as the "Rule files" section of SPEC.md describes. They are read
|
||||||
// at start, and again once the directory has had no change for a short
|
// at start, and again once the directory has had no change for a short
|
||||||
// time after one is edited, added or removed.
|
// time after one is edited, added or removed.
|
||||||
@@ -40,7 +40,7 @@ const (
|
|||||||
// extension ends the name of every rule file.
|
// extension ends the name of every rule file.
|
||||||
const extension = ".rules"
|
const extension = ".rules"
|
||||||
|
|
||||||
// quietTime is how long SWW_RULES_DIR must go without a change before
|
// quietTime is how long SWWAF_RULES_DIR must go without a change before
|
||||||
// the rule files are read again, so that a file still being written, such
|
// the rule files are read again, so that a file still being written, such
|
||||||
// as one saved in place, appended to or copied in with scp, is read only
|
// as one saved in place, appended to or copied in with scp, is read only
|
||||||
// once whole.
|
// once whole.
|
||||||
@@ -100,19 +100,19 @@ type Rule struct {
|
|||||||
|
|
||||||
// Params are what Load needs.
|
// Params are what Load needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
// Dir is the directory of the rule files (SWW_RULES_DIR).
|
// Dir is the directory of the rule files (SWWAF_RULES_DIR).
|
||||||
Dir string
|
Dir string
|
||||||
// Enabled is SWW_RULES_ENABLED: while it is false, no file is read
|
// Enabled is SWWAF_RULES_ENABLED: while it is false, no file is read
|
||||||
// and no rule loaded.
|
// and no rule loaded.
|
||||||
Enabled bool
|
Enabled bool
|
||||||
// ProcessLog receives how many rules were read, and the error in a
|
// ProcessLog receives how many rules were read, and the error in a
|
||||||
// rule file edited while sww runs.
|
// rule file edited while smallwebwaf runs.
|
||||||
ProcessLog *slog.Logger
|
ProcessLog *slog.Logger
|
||||||
// Alerts receive a file_error alert for that error.
|
// Alerts receive a file_error alert for that error.
|
||||||
Alerts *alerts.Queue
|
Alerts *alerts.Queue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Files are the rule files of a running sww, and the rules read
|
// Files are the rule files of a running smallwebwaf, and the rules read
|
||||||
// from them. They are safe for concurrent use.
|
// from them. They are safe for concurrent use.
|
||||||
type Files struct {
|
type Files struct {
|
||||||
params Params
|
params Params
|
||||||
@@ -271,7 +271,7 @@ func (f *Files) logRead(count int) {
|
|||||||
func read(dir string) ([]Rule, string, error) {
|
func read(dir string) ([]Rule, string, error) {
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, dir, fmt.Errorf("SWW_RULES_DIR cannot be read: %w", err)
|
return nil, dir, fmt.Errorf("SWWAF_RULES_DIR cannot be read: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var rules []Rule
|
var rules []Rule
|
||||||
@@ -299,7 +299,7 @@ func read(dir string) ([]Rule, string, error) {
|
|||||||
// readFile appends the rules of the rule file at path to rules. places
|
// readFile appends the rules of the rule file at path to rules. places
|
||||||
// are where each id read so far is, and gain those of the file.
|
// are where each id read so far is, and gain those of the file.
|
||||||
func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) {
|
func readFile(path string, rules []Rule, places map[string]string) ([]Rule, error) {
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWW_RULES_DIR
|
data, err := os.ReadFile(path) //nolint:gosec // a rule file, in SWWAF_RULES_DIR
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ func TestDirectoryThatDoesNotExistStopsTheStart(t *testing.T) {
|
|||||||
|
|
||||||
dir := filepath.Join(t.TempDir(), "rules.d")
|
dir := filepath.Join(t.TempDir(), "rules.d")
|
||||||
|
|
||||||
wantRefused(t, dir, "SWW_RULES_DIR cannot be read: open "+dir+
|
wantRefused(t, dir, "SWWAF_RULES_DIR cannot be read: open "+dir+
|
||||||
": no such file or directory")
|
": no such file or directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +321,7 @@ func TestEmptyDirectoryLoadsNoRulesAndSaysSo(t *testing.T) {
|
|||||||
func TestRuleFilesOffReadNothing(t *testing.T) {
|
func TestRuleFilesOffReadNothing(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// SWW_RULES_DIR does not exist, which would stop the start.
|
// SWWAF_RULES_DIR does not exist, which would stop the start.
|
||||||
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
|
params, _ := newParams(filepath.Join(t.TempDir(), "rules.d"))
|
||||||
params.Enabled = false
|
params.Enabled = false
|
||||||
|
|
||||||
@@ -601,7 +601,7 @@ func wantRefused(t *testing.T, dir, want string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// get returns a GET request for target, a path and an optional query, as
|
// get returns a GET request for target, a path and an optional query, as
|
||||||
// sww's server reads it, without a user agent.
|
// smallwebwaf's server reads it, without a user agent.
|
||||||
func get(t *testing.T, target string) *http.Request {
|
func get(t *testing.T, target string) *http.Request {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package discriminator
|
package servewaf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -49,9 +49,9 @@ func (rq *request) answerHealth() {
|
|||||||
_, _ = io.WriteString(rq.out, "ok\n")
|
_, _ = io.WriteString(rq.out, "ok\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// answerAdmin answers a request for one of discriminator's own endpoints. Each
|
// answerAdmin answers a request for one of servewaf's own endpoints. Each
|
||||||
// needs a token, sent as Authorization: Bearer <token>: the metrics
|
// needs a token, sent as Authorization: Bearer <token>: the metrics
|
||||||
// SWW_METRICS_TOKEN, the others SWW_ADMIN_TOKEN. A request without it is
|
// SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without it is
|
||||||
// refused with 401. An endpoint whose token is unset answers 404, as any other
|
// refused with 401. An endpoint whose token is unset answers 404, as any other
|
||||||
// path does.
|
// path does.
|
||||||
func (rq *request) answerAdmin() {
|
func (rq *request) answerAdmin() {
|
||||||
@@ -250,7 +250,7 @@ type clientAnswer struct {
|
|||||||
Bans []state.BanEntry `json:"bans"`
|
Bans []state.BanEntry `json:"bans"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// showClient answers GET ClientsPath<ip> with what discriminator knows of the client:
|
// showClient answers GET ClientsPath<ip> with what servewaf knows of the client:
|
||||||
// its counters, its history, which holds its country as last looked up and its
|
// its counters, its history, which holds its country as last looked up and its
|
||||||
// offences, and its bans with their notes.
|
// offences, and its bans with their notes.
|
||||||
func (rq *request) showClient() {
|
func (rq *request) showClient() {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package discriminator_test
|
package servewaf_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -10,15 +10,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/state"
|
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
adminToken = "SWW_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||||
// adminSecret is the SWW_ADMIN_TOKEN the tests set, and adminBearer
|
// adminSecret is the SWWAF_ADMIN_TOKEN the tests set, and adminBearer
|
||||||
// how a request carries it.
|
// how a request carries it.
|
||||||
adminSecret = "fedcba9876543210fedcba9876543210"
|
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||||
adminBearer = "Bearer " + adminSecret
|
adminBearer = "Bearer " + adminSecret
|
||||||
@@ -86,13 +86,13 @@ func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Any other request under /_sww/ is not found.
|
// Any other request under /_smallwebwaf/ is not found.
|
||||||
for _, e := range []adminEndpoint{
|
for _, e := range []adminEndpoint{
|
||||||
{http.MethodPut, discriminator.BansPath, banOtherClient},
|
{http.MethodPut, servewaf.BansPath, banOtherClient},
|
||||||
{http.MethodDelete, discriminator.BansPath, ""},
|
{http.MethodDelete, servewaf.BansPath, ""},
|
||||||
{http.MethodGet, discriminator.BansPath + "/" + otherClient, ""},
|
{http.MethodGet, servewaf.BansPath + "/" + otherClient, ""},
|
||||||
{http.MethodPost, discriminator.ClientsPath + otherClient, ""},
|
{http.MethodPost, servewaf.ClientsPath + otherClient, ""},
|
||||||
{http.MethodGet, strings.TrimSuffix(discriminator.ClientsPath, "/"), ""},
|
{http.MethodGet, strings.TrimSuffix(servewaf.ClientsPath, "/"), ""},
|
||||||
} {
|
} {
|
||||||
s.admin(e.method, e.path, e.body, http.StatusNotFound)
|
s.admin(e.method, e.path, e.body, http.StatusNotFound)
|
||||||
}
|
}
|
||||||
@@ -118,10 +118,10 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
|||||||
Reason: "probes for logins",
|
Reason: "probes for logins",
|
||||||
}
|
}
|
||||||
|
|
||||||
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, banOtherClient,
|
wantBans(t, s.admin(http.MethodPost, servewaf.BansPath, banOtherClient, http.StatusOK),
|
||||||
http.StatusOK), want)
|
want)
|
||||||
|
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
if line.BanExpires != requestlog.FormatTime(expires) {
|
if line.BanExpires != requestlog.FormatTime(expires) {
|
||||||
t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires)
|
t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires)
|
||||||
}
|
}
|
||||||
@@ -129,7 +129,7 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
|||||||
// Its notes count the request it refused.
|
// Its notes count the request it refused.
|
||||||
want.Notes.Requests, want.Notes.Refused = 1, 1
|
want.Notes.Requests, want.Notes.Refused = 1, 1
|
||||||
|
|
||||||
wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want)
|
wantBans(t, s.admin(http.MethodGet, servewaf.BansPath, "", http.StatusOK), want)
|
||||||
|
|
||||||
// Ten minutes on, lifting the bans on client lifts that one, which is
|
// Ten minutes on, lifting the bans on client lifts that one, which is
|
||||||
// kept, marked lifted.
|
// kept, marked lifted.
|
||||||
@@ -138,13 +138,13 @@ func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
|||||||
lifted := clk.Now()
|
lifted := clk.Now()
|
||||||
want.Lifted = &lifted
|
want.Lifted = &lifted
|
||||||
|
|
||||||
wantBans(t, s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "",
|
wantBans(t, s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "",
|
||||||
http.StatusOK), want)
|
http.StatusOK), want)
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
wantBans(t, s.admin(http.MethodGet, discriminator.BansPath, "", http.StatusOK), want)
|
wantBans(t, s.admin(http.MethodGet, servewaf.BansPath, "", http.StatusOK), want)
|
||||||
|
|
||||||
// No ban on it is active any more.
|
// No ban on it is active any more.
|
||||||
s.admin(http.MethodDelete, discriminator.BansPath+"/"+client, "", http.StatusNotFound)
|
s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "", http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
||||||
@@ -181,8 +181,7 @@ func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
|||||||
want.Expires = &expires
|
want.Expires = &expires
|
||||||
}
|
}
|
||||||
|
|
||||||
wantBans(t, s.admin(http.MethodPost, discriminator.BansPath, body, http.StatusOK),
|
wantBans(t, s.admin(http.MethodPost, servewaf.BansPath, body, http.StatusOK), want)
|
||||||
want)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,8 +250,7 @@ func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
|
|||||||
"request body too large",
|
"request body too large",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
got := s.admin(http.MethodPost, discriminator.BansPath, tc.body,
|
got := s.admin(http.MethodPost, servewaf.BansPath, tc.body, http.StatusBadRequest)
|
||||||
http.StatusBadRequest)
|
|
||||||
if !strings.Contains(string(got.body), tc.want) {
|
if !strings.Contains(string(got.body), tc.want) {
|
||||||
t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want)
|
t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want)
|
||||||
}
|
}
|
||||||
@@ -276,7 +274,7 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
|||||||
// limit of two a minute, and bans it.
|
// limit of two a minute, and bans it.
|
||||||
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
// Asked about by its address in IPv6 form too.
|
// Asked about by its address in IPv6 form too.
|
||||||
for _, addr := range []string{otherClient, "::ffff:" + otherClient} {
|
for _, addr := range []string{otherClient, "::ffff:" + otherClient} {
|
||||||
@@ -285,8 +283,7 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
|||||||
Bans []state.BanEntry `json:"bans"`
|
Bans []state.BanEntry `json:"bans"`
|
||||||
}
|
}
|
||||||
|
|
||||||
decode(t, s.admin(http.MethodGet, discriminator.ClientsPath+addr, "",
|
decode(t, s.admin(http.MethodGet, servewaf.ClientsPath+addr, "", http.StatusOK), &got)
|
||||||
http.StatusOK), &got)
|
|
||||||
|
|
||||||
if got.Client == nil {
|
if got.Client == nil {
|
||||||
t.Fatalf("%s: no client", addr)
|
t.Fatalf("%s: no client", addr)
|
||||||
@@ -308,15 +305,13 @@ func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
|||||||
|
|
||||||
// Of an address no request came from and no ban covers, nothing is
|
// Of an address no request came from and no ban covers, nothing is
|
||||||
// known.
|
// known.
|
||||||
got := s.admin(http.MethodGet, discriminator.ClientsPath+"198.51.100.99", "",
|
got := s.admin(http.MethodGet, servewaf.ClientsPath+"198.51.100.99", "", http.StatusOK)
|
||||||
http.StatusOK)
|
|
||||||
if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
|
if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
|
||||||
t.Errorf("an unknown client is answered\n%s", got.body)
|
t.Errorf("an unknown client is answered\n%s", got.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.admin(http.MethodGet, discriminator.ClientsPath+"203.0.113", "",
|
s.admin(http.MethodGet, servewaf.ClientsPath+"203.0.113", "", http.StatusBadRequest)
|
||||||
http.StatusBadRequest)
|
s.admin(http.MethodDelete, servewaf.BansPath+"/203.0.113.0/24", "",
|
||||||
s.admin(http.MethodDelete, discriminator.BansPath+"/203.0.113.0/24", "",
|
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,24 +332,24 @@ func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminEndpoint is a request to an endpoint SWW_ADMIN_TOKEN opens.
|
// adminEndpoint is a request to an endpoint SWWAF_ADMIN_TOKEN opens.
|
||||||
type adminEndpoint struct {
|
type adminEndpoint struct {
|
||||||
method, path, body string
|
method, path, body string
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminEndpoints returns a request to each endpoint SWW_ADMIN_TOKEN
|
// adminEndpoints returns a request to each endpoint SWWAF_ADMIN_TOKEN
|
||||||
// opens: listing the bans, banning otherClient for an hour, lifting the
|
// opens: listing the bans, banning otherClient for an hour, lifting the
|
||||||
// bans on otherClient, and asking about otherClient.
|
// bans on otherClient, and asking about otherClient.
|
||||||
func adminEndpoints() []adminEndpoint {
|
func adminEndpoints() []adminEndpoint {
|
||||||
return []adminEndpoint{
|
return []adminEndpoint{
|
||||||
{http.MethodGet, discriminator.BansPath, ""},
|
{http.MethodGet, servewaf.BansPath, ""},
|
||||||
{http.MethodPost, discriminator.BansPath, banOtherClient},
|
{http.MethodPost, servewaf.BansPath, banOtherClient},
|
||||||
{http.MethodDelete, discriminator.BansPath + "/" + otherClient, ""},
|
{http.MethodDelete, servewaf.BansPath + "/" + otherClient, ""},
|
||||||
{http.MethodGet, discriminator.ClientsPath + otherClient, ""},
|
{http.MethodGet, servewaf.ClientsPath + otherClient, ""},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// admin sends a request with method for path, with body, to discriminator's own
|
// admin sends a request with method for path, with body, to servewaf's own
|
||||||
// address, with the admin token, and checks that it is answered with status, its
|
// address, with the admin token, and checks that it is answered with status, its
|
||||||
// log line's action admin. It returns the answer.
|
// log line's action admin. It returns the answer.
|
||||||
func (s *sender) admin(method, path, body string, status int) answer {
|
func (s *sender) admin(method, path, body string, status int) answer {
|
||||||
@@ -370,7 +365,7 @@ func (s *sender) adminRequest(
|
|||||||
) answer {
|
) answer {
|
||||||
s.t.Helper()
|
s.t.Helper()
|
||||||
|
|
||||||
req := newRequest(s.t, method, s.discriminatorAddr, path, strings.NewReader(body))
|
req := newRequest(s.t, method, s.serveWAFAddr, path, strings.NewReader(body))
|
||||||
if authorization != "" {
|
if authorization != "" {
|
||||||
req.Header.Set("Authorization", authorization)
|
req.Header.Set("Authorization", authorization)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package discriminator_test
|
package servewaf_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
"maps"
|
||||||
@@ -10,13 +10,13 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
alertWebhookURL = "SWW_ALERT_WEBHOOK_URL"
|
alertWebhookURL = "SWWAF_ALERT_WEBHOOK_URL"
|
||||||
alertMaxPerHour = "SWW_ALERT_MAX_PER_HOUR"
|
alertMaxPerHour = "SWWAF_ALERT_MAX_PER_HOUR"
|
||||||
// alertInstance is the instance every alert of these tests gives.
|
// alertInstance is the instance every alert of these tests gives.
|
||||||
alertInstance = "fsn1app1/blog"
|
alertInstance = "fsn1app1/blog"
|
||||||
)
|
)
|
||||||
@@ -31,14 +31,14 @@ func TestBanForABrokenLimitRaisesABanAlertWithItsNotes(t *testing.T) {
|
|||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
netblock := netip.MustParsePrefix("203.0.113.0/24")
|
||||||
ban := server.Ledger.Bans(netblock)[0]
|
ban := server.Ledger.Bans(netblock)[0]
|
||||||
|
|
||||||
// A request refused under the ban raises no other alert.
|
// A request refused under the ban raises no other alert.
|
||||||
clk.advance(time.Minute)
|
clk.advance(time.Minute)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, bans.Ban{
|
wantAlerts(t, queue, banAlert(alerts.EventBan, start, client, bans.Ban{
|
||||||
Netblock: netblock, Cause: bans.CauseLimit,
|
Netblock: netblock, Cause: bans.CauseLimit,
|
||||||
@@ -62,24 +62,22 @@ func TestAttackBanRaisesABanAlertThenAPermanentBanAlert(t *testing.T) {
|
|||||||
|
|
||||||
// The probe bans the client for seven days, and its next request makes
|
// The probe bans the client for seven days, and its next request makes
|
||||||
// the ban permanent. The request after that changes nothing.
|
// the ban permanent. The request after that changes nothing.
|
||||||
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.request(client, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
attackBan := server.Ledger.Bans(netblock)[0]
|
attackBan := server.Ledger.Bans(netblock)[0]
|
||||||
|
|
||||||
clk.advance(time.Minute)
|
clk.advance(time.Minute)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
permanentBan := server.Ledger.Bans(netblock)[0]
|
permanentBan := server.Ledger.Bans(netblock)[0]
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
// Another client's probe after its first ban has run out without a
|
// Another client's probe after its first ban has run out without a
|
||||||
// request makes a permanent ban at once.
|
// request makes a permanent ban at once.
|
||||||
s.request(otherClient, "/.env", http.StatusForbidden,
|
s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
requestlog.ActionTemporarilyBanned)
|
|
||||||
clk.advance(7 * 24 * time.Hour)
|
clk.advance(7 * 24 * time.Hour)
|
||||||
s.request(otherClient, "/.env", http.StatusForbidden,
|
s.request(otherClient, "/.env", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
requestlog.ActionPermanentlyBanned)
|
|
||||||
|
|
||||||
otherBans := server.Ledger.Bans(other)
|
otherBans := server.Ledger.Bans(other)
|
||||||
|
|
||||||
@@ -203,7 +201,7 @@ func TestObserveModeWorksOutABanOnlyWhenItsAlertWouldBeSent(t *testing.T) {
|
|||||||
// never sent them, and returns the queue they wait in as well.
|
// never sent them, and returns the queue they wait in as well.
|
||||||
func startWithAlerts(
|
func startWithAlerts(
|
||||||
t *testing.T, env map[string]string,
|
t *testing.T, env map[string]string,
|
||||||
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
|
return startAppWithAlerts(t, func(http.ResponseWriter, *http.Request) {}, env)
|
||||||
@@ -212,21 +210,21 @@ func startWithAlerts(
|
|||||||
// startAppWithAlerts is startWithAlerts in front of the app handler.
|
// startAppWithAlerts is startWithAlerts in front of the app handler.
|
||||||
func startAppWithAlerts(
|
func startAppWithAlerts(
|
||||||
t *testing.T, handler http.HandlerFunc, env map[string]string,
|
t *testing.T, handler http.HandlerFunc, env map[string]string,
|
||||||
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
app := startApp(t, handler)
|
app := startApp(t, handler)
|
||||||
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)}
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
alertWebhookURL: "https://alerts.example/sww",
|
alertWebhookURL: "https://alerts.example/smallwebwaf",
|
||||||
instanceName: alertInstance,
|
instanceName: alertInstance,
|
||||||
}
|
}
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings)
|
addr, out, server, queue := startProxyWithAlerts(t, app.URL, "", clk.Now, settings)
|
||||||
|
|
||||||
s := &sender{t: t, addr: addr, discriminatorAddr: server.Addr, out: out}
|
s := &sender{t: t, addr: addr, serveWAFAddr: server.Addr, out: out}
|
||||||
|
|
||||||
return s, clk, server, queue
|
return s, clk, server, queue
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package discriminator
|
package servewaf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package discriminator_test
|
package servewaf_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
"maps"
|
||||||
@@ -13,19 +13,19 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The anomaly thresholds: the prefix of a scope followed by the end of a
|
// The anomaly thresholds: the prefix of a scope followed by the end of a
|
||||||
// count.
|
// count.
|
||||||
const (
|
const (
|
||||||
anomalyClient = "SWW_ANOMALY_CLIENT_"
|
anomalyClient = "SWWAF_ANOMALY_CLIENT_"
|
||||||
anomalyNet = "SWW_ANOMALY_NET_"
|
anomalyNet = "SWWAF_ANOMALY_NET_"
|
||||||
anomalyASN = "SWW_ANOMALY_ASN_"
|
anomalyASN = "SWWAF_ANOMALY_ASN_"
|
||||||
anomalyTotal = "SWW_ANOMALY_TOTAL_"
|
anomalyTotal = "SWWAF_ANOMALY_TOTAL_"
|
||||||
anomalyWatch = "SWW_WATCH_"
|
anomalyWatch = "SWWAF_WATCH_"
|
||||||
|
|
||||||
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
requestsPerMinute = "REQUESTS_PER_MINUTE"
|
||||||
requestsPerHour = "REQUESTS_PER_HOUR"
|
requestsPerHour = "REQUESTS_PER_HOUR"
|
||||||
@@ -35,9 +35,9 @@ const (
|
|||||||
|
|
||||||
// The other anomaly settings.
|
// The other anomaly settings.
|
||||||
const (
|
const (
|
||||||
anomalyNetV4Prefix = "SWW_ANOMALY_NET_V4_PREFIX"
|
anomalyNetV4Prefix = "SWWAF_ANOMALY_NET_V4_PREFIX"
|
||||||
anomalyNetV6Prefix = "SWW_ANOMALY_NET_V6_PREFIX"
|
anomalyNetV6Prefix = "SWWAF_ANOMALY_NET_V6_PREFIX"
|
||||||
watchNets = "SWW_WATCH_NETS"
|
watchNets = "SWWAF_WATCH_NETS"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -164,9 +164,9 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.7" // in SWW_ALLOW_NETS
|
allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS
|
||||||
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||||
denied = "192.0.2.20" // in SWW_DENY_NETS
|
denied = "192.0.2.20" // in SWWAF_DENY_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
s, _, _, queue := startAppWithAlerts(t, readAndAnswer, map[string]string{
|
||||||
@@ -187,7 +187,7 @@ func TestEveryRequestIsCountedWhateverIsDoneWithIt(t *testing.T) {
|
|||||||
{allowed, "/", http.StatusOK, requestlog.ActionForward},
|
{allowed, "/", http.StatusOK, requestlog.ActionForward},
|
||||||
{exempt, "/", http.StatusOK, requestlog.ActionForward},
|
{exempt, "/", http.StatusOK, requestlog.ActionForward},
|
||||||
{client, "/static/app.js", http.StatusOK, requestlog.ActionForward},
|
{client, "/static/app.js", http.StatusOK, requestlog.ActionForward},
|
||||||
{denied, "/", http.StatusForbidden, requestlog.ActionClientDenied},
|
{denied, "/", http.StatusForbidden, requestlog.ActionDenied},
|
||||||
} {
|
} {
|
||||||
for range 3 {
|
for range 3 {
|
||||||
s.request(sent.from, sent.path, sent.status, sent.action)
|
s.request(sent.from, sent.path, sent.status, sent.action)
|
||||||
@@ -319,7 +319,7 @@ func TestRequestCountsForTheASNumberGeoJSGivesBeforeItEnds(t *testing.T) {
|
|||||||
// kept.
|
// kept.
|
||||||
geojsURL, _, release := startHeldGeoJS(t)
|
geojsURL, _, release := startHeldGeoJS(t)
|
||||||
|
|
||||||
var server atomic.Pointer[discriminator.Server]
|
var server atomic.Pointer[servewaf.Server]
|
||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
||||||
release()
|
release()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package discriminator_test
|
package servewaf_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -15,25 +15,25 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
|
// answerTimeLimit is the default of rproxy's SWRP_TIMEOUT_MS, the time
|
||||||
// discriminator has to answer.
|
// servewaf has to answer.
|
||||||
answerTimeLimit = 2 * time.Second
|
answerTimeLimit = 2 * time.Second
|
||||||
// defaultLookupWait is the default of SWW_LOOKUP_TIMEOUT.
|
// defaultLookupWait is the default of SWWAF_LOOKUP_TIMEOUT.
|
||||||
defaultLookupWait = 800 * time.Millisecond
|
defaultLookupWait = 800 * time.Millisecond
|
||||||
// The header limits SPEC.md gives.
|
// The header limits SPEC.md gives.
|
||||||
cookieLimit = 50
|
cookieLimit = 50
|
||||||
headerFieldLimit = 100
|
headerFieldLimit = 100
|
||||||
// cookieHeader is the header the cookie limit counts.
|
// cookieHeader is the header the cookie limit counts.
|
||||||
cookieHeader = "Cookie"
|
cookieHeader = "Cookie"
|
||||||
// tooLarge is how discriminator refuses a request over the header limits.
|
// tooLarge is how servewaf refuses a request over the header limits.
|
||||||
tooLarge = http.StatusRequestHeaderFieldsTooLarge
|
tooLarge = http.StatusRequestHeaderFieldsTooLarge
|
||||||
// silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in
|
// silentAbuseIPDBURL is where AbuseIPDB never answers: at the stand-in
|
||||||
// that unansweredGeoJSURL names too.
|
// that unansweredGeoJSURL names too.
|
||||||
@@ -56,7 +56,7 @@ func cookies(count int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fillHeaders adds headers to a question until the checks see total header
|
// fillHeaders adds headers to a question until the checks see total header
|
||||||
// fields: all but ClientHeader and PeerHeader, which discriminator removes. Go's
|
// fields: all but ClientHeader and PeerHeader, which servewaf removes. Go's
|
||||||
// client sends nothing else but Host, which the limit does not count, once
|
// client sends nothing else but Host, which the limit does not count, once
|
||||||
// User-Agent is set.
|
// User-Agent is set.
|
||||||
func fillHeaders(req *http.Request, total int) {
|
func fillHeaders(req *http.Request, total int) {
|
||||||
@@ -70,7 +70,7 @@ func fillHeaders(req *http.Request, total int) {
|
|||||||
func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startDiscriminator(t, "", map[string]string{wafMode: block})
|
s := startServeWAF(t, "", map[string]string{wafMode: block})
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||||||
|
|
||||||
@@ -81,28 +81,16 @@ func TestSixteenThousandCookiesAreRefusedWellWithinTheTimeLimit(t *testing.T) {
|
|||||||
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
wantDisposition(t, got, tooLarge, servewaf.DispositionDeny)
|
||||||
|
|
||||||
line := s.out.requestLine(t)
|
line := s.out.requestLine(t)
|
||||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||||||
|
|
||||||
if line.DurationWAF != nil {
|
if line.DurationWAF != nil {
|
||||||
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationWAF)
|
t.Errorf("the Core Rule Set read the request, for %f ms", *line.DurationWAF)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHeaderLimitRefusalNamesItsHeuristicCountsAndLimits(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := startDiscriminator(t, "", nil)
|
|
||||||
req := s.question(siteA, client, "/")
|
|
||||||
fillHeaders(req, headerFieldLimit+1)
|
|
||||||
do(t, req)
|
|
||||||
|
|
||||||
wantHeuristic(t, s.out.requestLine(t), "header_limits",
|
|
||||||
`{"fields": 101, "fields_limit": 100, "cookies": 0, "cookies_limit": 50}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -148,7 +136,7 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startDiscriminator(t, "", map[string]string{wafMode: block})
|
s := startServeWAF(t, "", map[string]string{wafMode: block})
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
tc.make(req)
|
tc.make(req)
|
||||||
|
|
||||||
@@ -156,13 +144,13 @@ func TestEachHeaderLimitRefusesARequestOverIt(t *testing.T) {
|
|||||||
line := s.out.requestLine(t)
|
line := s.out.requestLine(t)
|
||||||
|
|
||||||
if tc.refused {
|
if tc.refused {
|
||||||
wantDisposition(t, got, tooLarge, discriminator.DispositionDeny)
|
wantDisposition(t, got, tooLarge, servewaf.DispositionDeny)
|
||||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
||||||
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
if line.DurationWAF == nil {
|
if line.DurationWAF == nil {
|
||||||
@@ -178,16 +166,16 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
|
|||||||
for _, env := range []map[string]string{
|
for _, env := range []map[string]string{
|
||||||
{wafMode: off},
|
{wafMode: off},
|
||||||
{wafMode: "detect"},
|
{wafMode: "detect"},
|
||||||
{wafMode: block, "SWW_WAF_EXEMPT_PATHS": "/"},
|
{wafMode: block, "SWWAF_WAF_EXEMPT_PATHS": "/"},
|
||||||
} {
|
} {
|
||||||
t.Run(fmt.Sprint(env), func(t *testing.T) {
|
t.Run(fmt.Sprint(env), func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startDiscriminator(t, "", env)
|
s := startServeWAF(t, "", env)
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
|
wantDisposition(t, do(t, req), tooLarge, servewaf.DispositionDeny)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,17 +183,17 @@ func TestHeaderLimitsHoldWhateverTheCoreRuleSetSettingsSay(t *testing.T) {
|
|||||||
func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
|
func TestClientInTheAllowNetsSkipsTheHeaderLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startDiscriminator(t, "", map[string]string{wafMode: block, allowNets: client})
|
s := startServeWAF(t, "", map[string]string{wafMode: block, allowNets: client})
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
wantDisposition(t, do(t, req), http.StatusOK, discriminator.DispositionAllow)
|
wantDisposition(t, do(t, req), http.StatusOK, servewaf.DispositionAllow)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startDiscriminator(t, "", map[string]string{mode: observe, wafMode: block})
|
s := startServeWAF(t, "", map[string]string{mode: observe, wafMode: block})
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
req.Header.Set(cookieHeader, strings.Repeat("a;", 16000))
|
||||||
|
|
||||||
@@ -216,20 +204,19 @@ func TestObserveModeAllowsARequestOverTheHeaderLimitsUnread(t *testing.T) {
|
|||||||
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
t.Errorf("answered after %s, want well within %s", took, answerTimeLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
||||||
|
|
||||||
line := s.out.requestLine(t)
|
line := s.out.requestLine(t)
|
||||||
if line.WouldAction != requestlog.ActionRequestHeaderTooLarge ||
|
if line.WouldAction != requestlog.ActionTooLarge || line.DurationWAF != nil {
|
||||||
line.DurationWAF != nil {
|
|
||||||
t.Errorf("would_action %q and duration_waf %v, want %q and none",
|
t.Errorf("would_action %q and duration_waf %v, want %q and none",
|
||||||
line.WouldAction, line.DurationWAF, requestlog.ActionRequestHeaderTooLarge)
|
line.WouldAction, line.DurationWAF, requestlog.ActionTooLarge)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startDiscriminator(t, "", map[string]string{rateLimitPerMinute: "1"})
|
s := startServeWAF(t, "", map[string]string{rateLimitPerMinute: "1"})
|
||||||
ask := func() answer {
|
ask := func() answer {
|
||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
@@ -237,11 +224,11 @@ func TestRequestsOverTheHeaderLimitsCountTowardTheRateLimits(t *testing.T) {
|
|||||||
return do(t, req)
|
return do(t, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantDisposition(t, ask(), tooLarge, discriminator.DispositionDeny)
|
wantDisposition(t, ask(), tooLarge, servewaf.DispositionDeny)
|
||||||
wantDisposition(t, ask(), http.StatusForbidden, discriminator.DispositionDeny)
|
wantDisposition(t, ask(), http.StatusForbidden, servewaf.DispositionDeny)
|
||||||
|
|
||||||
lines := s.out.requestLines(t, 2)
|
lines := s.out.requestLines(t, 2)
|
||||||
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
||||||
@@ -250,19 +237,19 @@ func TestTheHeaderLimitsComeAfterTheTrapPathsAndTheRuleFiles(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name, path, action string
|
name, path, action string
|
||||||
}{
|
}{
|
||||||
{"a trap path", "/wp-login.php", requestlog.ActionTemporarilyBanned},
|
{"a trap path", "/wp-login.php", requestlog.ActionBanned},
|
||||||
{"a block rule", "/blocked", requestlog.ActionBlockedByBlockRule},
|
{"a block rule", "/blocked", requestlog.ActionRuleBlocked},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s := startDiscriminator(t, "", map[string]string{
|
s := startServeWAF(t, "", map[string]string{
|
||||||
trapPaths: trapPathList, rulesDir: writeRules(t, testRules),
|
trapPaths: trapPathList, rulesDir: writeRules(t, testRules),
|
||||||
})
|
})
|
||||||
req := s.question(siteA, client, tc.path)
|
req := s.question(siteA, client, tc.path)
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
|
wantDisposition(t, do(t, req), http.StatusForbidden, servewaf.DispositionDeny)
|
||||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action)
|
wantLine(t, s.out.requestLine(t), http.StatusForbidden, tc.action)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -272,30 +259,30 @@ func TestNotWaitingForGeoJSNeverLowersAClientsLimit(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s := startDiscriminator(t, geojsURL, map[string]string{
|
s := startServeWAF(t, geojsURL, map[string]string{
|
||||||
unknownLimitPercent: "0", lookupTimeout: "1h",
|
unknownLimitPercent: "0", lookupTimeout: "1h",
|
||||||
})
|
})
|
||||||
req := s.question(siteA, fromDE, "/")
|
req := s.question(siteA, fromDE, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
wantDisposition(t, do(t, req), tooLarge, discriminator.DispositionDeny)
|
wantDisposition(t, do(t, req), tooLarge, servewaf.DispositionDeny)
|
||||||
|
|
||||||
line := s.out.requestLine(t)
|
line := s.out.requestLine(t)
|
||||||
wantLine(t, line, tooLarge, requestlog.ActionRequestHeaderTooLarge)
|
wantLine(t, line, tooLarge, requestlog.ActionTooLarge)
|
||||||
|
|
||||||
if line.LimitPercent != nil {
|
if line.LimitPercent != nil {
|
||||||
t.Errorf("limit percentage %d, want none", *line.LimitPercent)
|
t.Errorf("limit percentage %d, want none", *line.LimitPercent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The client is under no ban, and GeoJS places it in a country.
|
// The client is under no ban, and GeoJS places it in a country.
|
||||||
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, discriminator.DispositionAllow)
|
wantDisposition(t, s.ask(fromDE, "/"), http.StatusOK, servewaf.DispositionAllow)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
|
func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s := startDiscriminator(t, geojsURL, map[string]string{unknownLimitPercent: "0"})
|
s := startServeWAF(t, geojsURL, map[string]string{unknownLimitPercent: "0"})
|
||||||
s.server.GeoJS.Load([]lookup.Answer{{
|
s.server.GeoJS.Load([]lookup.Answer{{
|
||||||
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
|
Client: netip.MustParsePrefix(client + "/32"), ASN: "AS64512",
|
||||||
Answered: s.clock.Now(), Used: s.clock.Now(),
|
Answered: s.clock.Now(), Used: s.clock.Now(),
|
||||||
@@ -304,23 +291,22 @@ func TestAClientGeoJSPlacedInNoCountryCountsAsUnknownWithoutWaiting(t *testing.T
|
|||||||
req := s.question(siteA, client, "/")
|
req := s.question(siteA, client, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
wantDisposition(t, do(t, req), http.StatusForbidden, discriminator.DispositionDeny)
|
wantDisposition(t, do(t, req), http.StatusForbidden, servewaf.DispositionDeny)
|
||||||
wantLine(t, s.out.requestLine(t), http.StatusForbidden,
|
wantLine(t, s.out.requestLine(t), http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
requestlog.ActionClientIsRateLimited)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
s := startDiscriminator(t, geojsURL, map[string]string{
|
s := startServeWAF(t, geojsURL, map[string]string{
|
||||||
mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
|
mode: observe, addLookupHeaders: on, lookupTimeout: "1h",
|
||||||
})
|
})
|
||||||
req := s.question(siteA, fromDE, "/")
|
req := s.question(siteA, fromDE, "/")
|
||||||
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
req.Header.Set(cookieHeader, cookies(cookieLimit+1))
|
||||||
|
|
||||||
got := do(t, req)
|
got := do(t, req)
|
||||||
wantDisposition(t, got, http.StatusOK, discriminator.DispositionAllow)
|
wantDisposition(t, got, http.StatusOK, servewaf.DispositionAllow)
|
||||||
|
|
||||||
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
|
asn, country := got.header.Get("X-Client-Asn"), got.header.Get("X-Client-Country")
|
||||||
if asn != asnDE || country != "DE" {
|
if asn != asnDE || country != "DE" {
|
||||||
@@ -329,18 +315,18 @@ func TestObserveModeWaitsForGeoJSForARequestOverTheHeaderLimits(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newDiscriminatorInBubble makes a discriminator for a synctest bubble: GeoJS never
|
// newServeWAFInBubble makes a servewaf for a synctest bubble: GeoJS never
|
||||||
// answers, a setting needs its answer, and everything is at its default but
|
// answers, a setting needs its answer, and everything is at its default but
|
||||||
// for env. No request to it may use the network.
|
// for env. No request to it may use the network.
|
||||||
func newDiscriminatorInBubble(
|
func newServeWAFInBubble(
|
||||||
t *testing.T, env map[string]string,
|
t *testing.T, env map[string]string,
|
||||||
) (*discriminator.Server, *output) {
|
) (*servewaf.Server, *output) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{tagDeny: "country=KP"}
|
settings := map[string]string{deniedCountries: "KP"}
|
||||||
maps.Copy(settings, env)
|
maps.Copy(settings, env)
|
||||||
|
|
||||||
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now, settings)
|
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now, settings)
|
||||||
|
|
||||||
return server, out
|
return server, out
|
||||||
}
|
}
|
||||||
@@ -348,13 +334,13 @@ func newDiscriminatorInBubble(
|
|||||||
// askDirectly hands a question about a request from client straight to server,
|
// askDirectly hands a question about a request from client straight to server,
|
||||||
// as change leaves it, and returns the answer's status and how long it took.
|
// as change leaves it, and returns the answer's status and how long it took.
|
||||||
func askDirectly(
|
func askDirectly(
|
||||||
t *testing.T, server *discriminator.Server, change func(*http.Request),
|
t *testing.T, server *servewaf.Server, change func(*http.Request),
|
||||||
) (int, time.Duration) {
|
) (int, time.Duration) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||||||
req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234")
|
req.RemoteAddr = net.JoinHostPort(rproxyPeer, "1234")
|
||||||
req.Header.Set(discriminator.ClientHeader, client)
|
req.Header.Set(servewaf.ClientHeader, client)
|
||||||
req.Header.Set(requestIDHeader, "the-id")
|
req.Header.Set(requestIDHeader, "the-id")
|
||||||
change(req)
|
change(req)
|
||||||
|
|
||||||
@@ -367,17 +353,17 @@ func askDirectly(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// banClient bans client at now.
|
// banClient bans client at now.
|
||||||
func banClient(_ *testing.T, server *discriminator.Server, now time.Time) {
|
func banClient(_ *testing.T, server *servewaf.Server, now time.Time) {
|
||||||
server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{})
|
server.Ledger.BanForLimit(netip.MustParsePrefix(client+"/32"), now, bans.Notes{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// countClient counts one request from client at now for the rate limits.
|
// countClient counts one request from client at now for the rate limits.
|
||||||
func countClient(_ *testing.T, server *discriminator.Server, now time.Time) {
|
func countClient(_ *testing.T, server *servewaf.Server, now time.Time) {
|
||||||
server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit)
|
server.Limiter.Count(netip.MustParsePrefix(client+"/32"), now, wholeLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// listClient puts client on the blocklist at dropURL.
|
// listClient puts client on the blocklist at dropURL.
|
||||||
func listClient(t *testing.T, server *discriminator.Server, now time.Time) {
|
func listClient(t *testing.T, server *servewaf.Server, now time.Time) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
err := server.Lists.Load([]reputation.List{{
|
err := server.Lists.Load([]reputation.List{{
|
||||||
@@ -389,13 +375,13 @@ func listClient(t *testing.T, server *discriminator.Server, now time.Time) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// decideOnClient puts client on the CrowdSec decision list.
|
// decideOnClient puts client on the CrowdSec decision list.
|
||||||
func decideOnClient(t *testing.T, server *discriminator.Server, now time.Time) {
|
func decideOnClient(t *testing.T, server *servewaf.Server, now time.Time) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
loadDecisions(t, server, now, clientsDecision)
|
loadDecisions(t, server, now, clientsDecision)
|
||||||
}
|
}
|
||||||
|
|
||||||
// leaveAsItIs prepares nothing.
|
// leaveAsItIs prepares nothing.
|
||||||
func leaveAsItIs(*testing.T, *discriminator.Server, time.Time) {}
|
func leaveAsItIs(*testing.T, *servewaf.Server, time.Time) {}
|
||||||
|
|
||||||
func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -406,45 +392,41 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
env map[string]string
|
env map[string]string
|
||||||
// prepare gets the server ready, at now, before the question.
|
// prepare gets the server ready, at now, before the question.
|
||||||
prepare func(t *testing.T, server *discriminator.Server, now time.Time)
|
prepare func(t *testing.T, server *servewaf.Server, now time.Time)
|
||||||
// cookie is the request's Cookie header.
|
// cookie is the request's Cookie header.
|
||||||
cookie string
|
cookie string
|
||||||
status int
|
status int
|
||||||
action string
|
action string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "SWW_DENY_NETS", env: map[string]string{denyNets: client},
|
name: "SWWAF_DENY_NETS", env: map[string]string{denyNets: client},
|
||||||
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionClientDenied,
|
prepare: leaveAsItIs, status: forbidden, action: requestlog.ActionDenied,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "a ban",
|
name: "a ban",
|
||||||
prepare: banClient, status: forbidden, action: requestlog.ActionTemporarilyBanned,
|
prepare: banClient, status: forbidden, action: requestlog.ActionBanned,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"},
|
name: "the rate limits", env: map[string]string{rateLimitPerMinute: "1"},
|
||||||
prepare: countClient, status: forbidden,
|
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
|
||||||
action: requestlog.ActionClientIsRateLimited,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "the rate limits, with a biased threshold",
|
name: "the rate limits, with a biased threshold",
|
||||||
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
|
env: map[string]string{rateLimitPerMinute: "1", unknownLimitPercent: "50"},
|
||||||
prepare: countClient, status: forbidden,
|
prepare: countClient, status: forbidden, action: requestlog.ActionRateLimited,
|
||||||
action: requestlog.ActionClientIsRateLimited,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "a blocklist", env: map[string]string{blocklistURLs: dropURL},
|
name: "a blocklist", env: map[string]string{blocklistURLs: dropURL},
|
||||||
prepare: listClient, status: forbidden, action: requestlog.ActionClientDenied,
|
prepare: listClient, status: forbidden, action: requestlog.ActionDenied,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "the CrowdSec decision list",
|
name: "the CrowdSec decision list",
|
||||||
env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey},
|
env: map[string]string{crowdSecURL: lapi, crowdSecKey: bouncerKey},
|
||||||
prepare: decideOnClient, status: forbidden,
|
prepare: decideOnClient, status: forbidden, action: requestlog.ActionBanned,
|
||||||
action: requestlog.ActionTemporarilyBanned,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "the header limits", cookie: cookies(cookieLimit + 1),
|
name: "the header limits", cookie: cookies(cookieLimit + 1),
|
||||||
prepare: leaveAsItIs, status: tooLarge,
|
prepare: leaveAsItIs, status: tooLarge, action: requestlog.ActionTooLarge,
|
||||||
action: requestlog.ActionRequestHeaderTooLarge,
|
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -453,7 +435,7 @@ func TestARefusedRequestDoesNotWaitForASlowGeoJS(t *testing.T) {
|
|||||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||||
// says, so that a request that does not wait takes no time at all.
|
// says, so that a request that does not wait takes no time at all.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
server, out := newDiscriminatorInBubble(t, tc.env)
|
server, out := newServeWAFInBubble(t, tc.env)
|
||||||
tc.prepare(t, server, time.Now())
|
tc.prepare(t, server, time.Now())
|
||||||
|
|
||||||
status, took := askDirectly(t, server, func(req *http.Request) {
|
status, took := askDirectly(t, server, func(req *http.Request) {
|
||||||
@@ -481,7 +463,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
env map[string]string
|
env map[string]string
|
||||||
// prepare gets the server ready, at now, before the question.
|
// prepare gets the server ready, at now, before the question.
|
||||||
prepare func(t *testing.T, server *discriminator.Server, now time.Time)
|
prepare func(t *testing.T, server *servewaf.Server, now time.Time)
|
||||||
status int
|
status int
|
||||||
action string
|
action string
|
||||||
}{
|
}{
|
||||||
@@ -510,7 +492,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
server, out := newDiscriminatorInBubble(t, tc.env)
|
server, out := newServeWAFInBubble(t, tc.env)
|
||||||
tc.prepare(t, server, time.Now())
|
tc.prepare(t, server, time.Now())
|
||||||
|
|
||||||
status, took := askDirectly(t, server, func(*http.Request) {})
|
status, took := askDirectly(t, server, func(*http.Request) {})
|
||||||
@@ -526,9 +508,7 @@ func TestAQuestionWaitsForASlowGeoJSOnlyAsLongAsTheDefaultSays(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
func TestServeWAFAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(t *testing.T) {
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
held, askedGeoJS, _ := startHeldGeoJS(t)
|
held, askedGeoJS, _ := startHeldGeoJS(t)
|
||||||
@@ -541,14 +521,12 @@ func TestDiscriminatorAnswersWithinTheTimeLimitWhileEveryOutsideSourceHangs(
|
|||||||
|
|
||||||
t.Cleanup(func() { _ = resolver.Close() })
|
t.Cleanup(func() { _ = resolver.Close() })
|
||||||
|
|
||||||
server, out, _ := newDiscriminatorWith(t, func(
|
server, out, _ := newServeWAFWith(t, func(params servewaf.Params) *servewaf.Server {
|
||||||
params discriminator.Params,
|
|
||||||
) *discriminator.Server {
|
|
||||||
params.AbuseIPDBURL = silentAbuseIPDBURL
|
params.AbuseIPDBURL = silentAbuseIPDBURL
|
||||||
|
|
||||||
return discriminator.New(params)
|
return servewaf.New(params)
|
||||||
}, held, time.Now, map[string]string{
|
}, held, time.Now, map[string]string{
|
||||||
tagDeny: "country=KP", wafMode: block,
|
deniedCountries: "KP", wafMode: block,
|
||||||
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
dnsblZones: dnsblZone, dnsblResolver: resolver.LocalAddr().String(),
|
||||||
abuseIPDBKey: accountKey,
|
abuseIPDBKey: accountKey,
|
||||||
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
|
blocklistURLs: held + "/drop.txt", asnLimitPercentURL: held + "/asn.txt",
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
package discriminator
|
package servewaf
|
||||||
|
|
||||||
// The heuristic active_ban, and the code that makes bans, which several
|
|
||||||
// heuristics share. active_ban reads SWW_MODE; the bans are the ban ledger's.
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,21 +12,17 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// banResponse is a refusal answered with SWW_BAN_RESPONSE, and logged
|
// banResponse is a refusal answered with SWWAF_BAN_RESPONSE, and logged
|
||||||
// with action.
|
// with action.
|
||||||
func (rq *request) banResponse(action string) *refusal {
|
func (rq *request) banResponse(action string) *refusal {
|
||||||
return &refusal{status: rq.h.config.BanResponse, action: action}
|
return &refusal{status: rq.h.config.BanResponse, action: action}
|
||||||
}
|
}
|
||||||
|
|
||||||
// activeBan refuses a client a ban covers: a ban on a netblock the client is
|
// banned reports whether a ban on a netblock the client is in covers the
|
||||||
// in, in force at now. It notes for the log line when that ban ends. A request
|
// request at now, and notes for the log line when that ban ends. A
|
||||||
// that makes the ban permanent, or in observe mode would have, raises the alert
|
// request that makes the ban permanent, or in observe mode would have,
|
||||||
// for it. Its figures are the ban's netblock and when it ends.
|
// raises the alert for it.
|
||||||
type activeBan struct{}
|
func (rq *request) banned(now time.Time) bool {
|
||||||
|
|
||||||
func (activeBan) name() string { return "active_ban" }
|
|
||||||
|
|
||||||
func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
|
|
||||||
check := rq.h.ledger.Check
|
check := rq.h.ledger.Check
|
||||||
if rq.h.config.Observe {
|
if rq.h.config.Observe {
|
||||||
check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is
|
check = rq.h.ledger.Find // the ban refuses nothing, and stays as it is
|
||||||
@@ -45,39 +38,90 @@ func (activeBan) check(_ context.Context, rq *request, now time.Time) *verdict {
|
|||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !banned {
|
return banned
|
||||||
return nil
|
}
|
||||||
|
|
||||||
|
// limitBroken counts the request for the rate limits at now, notes the
|
||||||
|
// client's counts for the log line, and reports whether the request takes
|
||||||
|
// the client over a rate limit, as its limit percentage lowers it, which
|
||||||
|
// breaks it.
|
||||||
|
func (rq *request) limitBroken(now time.Time) bool {
|
||||||
|
counts, hit, over := rq.h.limiter.Count(rq.h.clientGroup(rq.client), now,
|
||||||
|
rq.limitPercent.percent)
|
||||||
|
rq.line.Counts = counts
|
||||||
|
|
||||||
|
if over {
|
||||||
|
rq.banForLimit(now, hit, rq.h.config.BanResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &verdict{
|
return over
|
||||||
action: banAction(ban.Permanent()),
|
}
|
||||||
figures: map[string]any{
|
|
||||||
figureNetblock: ban.Netblock.String(), "ban_expires": rq.line.BanExpires,
|
// countBytes counts the request's bytes, as countedBytes gives them, for
|
||||||
},
|
// the byte limits, once its report has come, and notes the client's byte totals
|
||||||
|
// for the log line; its requests stay there as the rate limits counted them.
|
||||||
|
// Only an allowed request has them counted, and only one the rate limits
|
||||||
|
// counted; in observe mode, not one that enforce mode would have refused. Bytes
|
||||||
|
// that take the client over a byte limit, as its limit percentage for the byte
|
||||||
|
// limits lowers it, break it; the response was passed on whole.
|
||||||
|
func (rq *request) countBytes() {
|
||||||
|
if !rq.counted || rq.line.WouldAction != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := rq.h.now()
|
||||||
|
|
||||||
|
counts, hit, over := rq.h.limiter.CountBytes(rq.h.clientGroup(rq.client), now,
|
||||||
|
rq.countedBytes(), rq.bytesPercent.percent)
|
||||||
|
rq.line.Counts.MinuteBytes = counts.MinuteBytes
|
||||||
|
rq.line.Counts.HourBytes = counts.HourBytes
|
||||||
|
rq.line.Counts.DayBytes = counts.DayBytes
|
||||||
|
|
||||||
|
if over {
|
||||||
|
rq.banForLimit(now, hit, rq.out.status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// banAction is the action of a request refused under a ban or for making one:
|
// countRefusal counts the request for the error burst once it has been
|
||||||
// permanent says the ban never runs out.
|
// answered, if servewaf refused it after a rule file match, a trap path or a
|
||||||
func banAction(permanent bool) string {
|
// Core Rule Set match, and in observe mode if enforce mode would have: more than
|
||||||
if permanent {
|
// SWWAF_ERROR_BURST_THRESHOLD such refusals of the client within a minute
|
||||||
return requestlog.ActionPermanentlyBanned
|
// break a limit. A client in SWWAF_ALLOW_NETS, which the checks skip, is
|
||||||
|
// not counted, and nothing is while the threshold is off.
|
||||||
|
func (rq *request) countRefusal() {
|
||||||
|
cfg := rq.h.config
|
||||||
|
if cfg.ErrorBurstThreshold == 0 {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return requestlog.ActionTemporarilyBanned
|
if !rq.attack && !rq.ruleBlocked && !rq.wafBlocked {
|
||||||
}
|
return
|
||||||
|
|
||||||
// limitFigures are the figures of a heuristic that broke hit, a limit: its
|
|
||||||
// window, the count and the limit.
|
|
||||||
func limitFigures(hit ratelimit.Hit) map[string]any {
|
|
||||||
return map[string]any{
|
|
||||||
figureWindow: hit.Window, figureCount: hit.Count, figureLimit: hit.Limit,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := rq.h.now()
|
||||||
|
|
||||||
|
hit, over := rq.h.limiter.CountRefusal(rq.h.clientGroup(rq.client), now,
|
||||||
|
cfg.ErrorBurstThreshold)
|
||||||
|
if !over {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the client was sent, or in observe mode would have been.
|
||||||
|
status := rq.out.status
|
||||||
|
|
||||||
|
switch rq.line.WouldAction {
|
||||||
|
case requestlog.ActionRuleBlocked, requestlog.ActionWAFBlocked:
|
||||||
|
status = http.StatusForbidden
|
||||||
|
case requestlog.ActionBanned:
|
||||||
|
status = cfg.BanResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
rq.banForLimit(now, hit, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// countedBytes returns the request's bytes, once it has ended, as the
|
// countedBytes returns the request's bytes, once it has ended, as the
|
||||||
// byte limits and the anomaly thresholds count them: the response's body
|
// byte limits and the anomaly thresholds count them: the response's body
|
||||||
// bytes, the request's, or both, as SWW_BYTES_COUNT says.
|
// bytes, the request's, or both, as SWWAF_BYTES_COUNT says.
|
||||||
func (rq *request) countedBytes() int64 {
|
func (rq *request) countedBytes() int64 {
|
||||||
response, request := rq.out.bytes, rq.requestBytes
|
response, request := rq.out.bytes, rq.requestBytes
|
||||||
|
|
||||||
@@ -94,7 +138,7 @@ func (rq *request) countedBytes() int64 {
|
|||||||
// banForLimit bans the client's netblock at now for a broken limit, the
|
// banForLimit bans the client's netblock at now for a broken limit, the
|
||||||
// one hit names, notes the offence for the log line and counts the hit in
|
// one hit names, notes the offence for the log line and counts the hit in
|
||||||
// the metrics. status is what the client was sent, or is sent:
|
// the metrics. status is what the client was sent, or is sent:
|
||||||
// SWW_BAN_RESPONSE for a request over a rate limit, what the report says for
|
// SWWAF_BAN_RESPONSE for a request over a rate limit, what the report says for
|
||||||
// one whose bytes broke a byte limit, the refusal for one that broke the
|
// one whose bytes broke a byte limit, the refusal for one that broke the
|
||||||
// error burst. The ban's notes give the client's limit percentage for a
|
// error burst. The ban's notes give the client's limit percentage for a
|
||||||
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
// rate limit or a byte limit; the error burst is not lowered. The ban sets
|
||||||
@@ -161,11 +205,11 @@ func (rq *request) banForLimit(now time.Time, hit ratelimit.Hit, status int) {
|
|||||||
// attack, which notes name: the ban rule that matched, or the trap path
|
// attack, which notes name: the ban rule that matched, or the trap path
|
||||||
// asked for. It fills in the rest of the notes. In observe mode it makes
|
// asked for. It fills in the rest of the notes. In observe mode it makes
|
||||||
// no ban, and raises the alert for the ban it would have made, if that
|
// no ban, and raises the alert for the ban it would have made, if that
|
||||||
// alert would be sent. It returns the action for that ban.
|
// alert would be sent.
|
||||||
func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
|
func (rq *request) banForAttack(now time.Time, notes bans.Notes) {
|
||||||
netblock := rq.h.netblock(rq.client)
|
netblock := rq.h.netblock(rq.client)
|
||||||
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseAttack) {
|
||||||
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseAttack))
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
notes.ASN = rq.line.ASN
|
notes.ASN = rq.line.ASN
|
||||||
@@ -181,7 +225,7 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
|
|||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
return banAction(ban.Permanent())
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ban, made := rq.h.ledger.BanForAttack(netblock, now, notes)
|
ban, made := rq.h.ledger.BanForAttack(netblock, now, notes)
|
||||||
@@ -190,18 +234,16 @@ func (rq *request) banForAttack(now time.Time, notes bans.Notes) string {
|
|||||||
if made {
|
if made {
|
||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
return banAction(ban.Permanent())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// banForCrowdSec bans the client's netblock at now until decision,
|
// banForCrowdSec bans the client's netblock at now until decision,
|
||||||
// CrowdSec's decision on the client, ends. In observe mode it makes no
|
// CrowdSec's decision on the client, ends. In observe mode it makes no
|
||||||
// ban, and raises the alert for the ban it would have made, if that alert
|
// ban, and raises the alert for the ban it would have made, if that alert
|
||||||
// would be sent. It returns the action for that ban.
|
// would be sent.
|
||||||
func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) string {
|
func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) {
|
||||||
netblock := rq.h.netblock(rq.client)
|
netblock := rq.h.netblock(rq.client)
|
||||||
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) {
|
if rq.h.config.Observe && !rq.wouldAlertBan(netblock, now, bans.CauseCrowdSec) {
|
||||||
return banAction(rq.h.ledger.WouldBePermanent(netblock, now, bans.CauseCrowdSec))
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
notes := bans.Notes{
|
notes := bans.Notes{
|
||||||
@@ -220,7 +262,7 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) s
|
|||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
return banAction(ban.Permanent())
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ban, made := rq.h.ledger.BanForCrowdSec(netblock, now, decision.Expires,
|
ban, made := rq.h.ledger.BanForCrowdSec(netblock, now, decision.Expires,
|
||||||
@@ -230,14 +272,12 @@ func (rq *request) banForCrowdSec(now time.Time, decision reputation.Decision) s
|
|||||||
if made {
|
if made {
|
||||||
rq.alertBan(ban)
|
rq.alertBan(ban)
|
||||||
}
|
}
|
||||||
|
|
||||||
return banAction(ban.Permanent())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
// wouldAlertBan reports whether the alert for a ban on netblock for cause
|
||||||
// made at now would be sent. In observe mode the ban the request would
|
// made at now would be sent. In observe mode the ban the request would
|
||||||
// have made is worked out only then, at most once per
|
// have made is worked out only then, at most once per
|
||||||
// SWW_ALERT_COOLDOWN and never with no webhook set: its notes count the
|
// SWWAF_ALERT_COOLDOWN and never with no webhook set: its notes count the
|
||||||
// netblock's requests, which can mean going through every client.
|
// netblock's requests, which can mean going through every client.
|
||||||
func (rq *request) wouldAlertBan(
|
func (rq *request) wouldAlertBan(
|
||||||
netblock netip.Prefix, now time.Time, cause string,
|
netblock netip.Prefix, now time.Time, cause string,
|
||||||
@@ -303,7 +343,7 @@ func (rq *request) netblockRequests(netblock netip.Prefix) int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// netblock is the netblock a ban on client covers: its IPv4 address,
|
// netblock is the netblock a ban on client covers: its IPv4 address,
|
||||||
// widened to SWW_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
// widened to SWWAF_BAN_SCOPE_V4_PREFIX, or the IPv6 group clientGroup
|
||||||
// counts it in.
|
// counts it in.
|
||||||
func (h *handler) netblock(client netip.Addr) netip.Prefix {
|
func (h *handler) netblock(client netip.Addr) netip.Prefix {
|
||||||
addr := client.Unmap()
|
addr := client.Unmap()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package discriminator_test
|
package servewaf_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -26,7 +26,7 @@ const (
|
|||||||
userAgent = "ban-test/1.0"
|
userAgent = "ban-test/1.0"
|
||||||
// permanent is the log line's ban_expires for a permanent ban.
|
// permanent is the log line's ban_expires for a permanent ban.
|
||||||
permanent = "permanent"
|
permanent = "permanent"
|
||||||
// banResponseClose is the SWW_BAN_RESPONSE that closes the connection.
|
// banResponseClose is the SWWAF_BAN_RESPONSE that closes the connection.
|
||||||
banResponseClose = "close"
|
banResponseClose = "close"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ func TestBrokenLimitBansTheClient(t *testing.T) {
|
|||||||
// client for an hour, the default.
|
// client for an hour, the default.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
line := s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
|
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
|
||||||
line.BanExpires != expires {
|
line.BanExpires != expires {
|
||||||
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
|
t.Errorf("log line has limit_hit %q, offence %q and ban_expires %q, "+
|
||||||
@@ -51,7 +51,7 @@ func TestBrokenLimitBansTheClient(t *testing.T) {
|
|||||||
// Every request while the ban lasts is refused.
|
// Every request while the ban lasts is refused.
|
||||||
clk.advance(time.Hour - time.Second)
|
clk.advance(time.Hour - time.Second)
|
||||||
|
|
||||||
line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
line = s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
if line.BanExpires != expires || line.Offence != "" || line.LimitHit != "" {
|
if line.BanExpires != expires || line.Offence != "" || line.LimitHit != "" {
|
||||||
t.Errorf("log line has ban_expires %q, offence %q and limit_hit %q, "+
|
t.Errorf("log line has ban_expires %q, offence %q and limit_hit %q, "+
|
||||||
"want %s and neither of the others", line.BanExpires, line.Offence,
|
"want %s and neither of the others", line.BanExpires, line.Offence,
|
||||||
@@ -78,9 +78,7 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
|||||||
breakLimit := func() string {
|
breakLimit := func() string {
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
return s.get(client, http.StatusForbidden, requestlog.ActionRateLimited).BanExpires
|
||||||
|
|
||||||
return line.BanExpires
|
|
||||||
}
|
}
|
||||||
wantExpires := func(got string, length time.Duration) {
|
wantExpires := func(got string, length time.Duration) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -91,19 +89,19 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A first ban lasts SWW_LIMIT_BAN_DURATION; one within
|
// A first ban lasts SWWAF_LIMIT_BAN_DURATION; one within
|
||||||
// SWW_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
|
// SWWAF_LIMIT_BAN_REPEAT_WINDOW after it ended, three times as long.
|
||||||
wantExpires(breakLimit(), 10*time.Minute)
|
wantExpires(breakLimit(), 10*time.Minute)
|
||||||
clk.advance(10*time.Minute + time.Hour)
|
clk.advance(10*time.Minute + time.Hour)
|
||||||
wantExpires(breakLimit(), 30*time.Minute)
|
wantExpires(breakLimit(), 30*time.Minute)
|
||||||
|
|
||||||
// Later than that, SWW_LIMIT_BAN_DURATION again.
|
// Later than that, SWWAF_LIMIT_BAN_DURATION again.
|
||||||
clk.advance(30*time.Minute + time.Hour + time.Second)
|
clk.advance(30*time.Minute + time.Hour + time.Second)
|
||||||
wantExpires(breakLimit(), 10*time.Minute)
|
wantExpires(breakLimit(), 10*time.Minute)
|
||||||
clk.advance(10 * time.Minute)
|
clk.advance(10 * time.Minute)
|
||||||
wantExpires(breakLimit(), 30*time.Minute)
|
wantExpires(breakLimit(), 30*time.Minute)
|
||||||
|
|
||||||
// 90 minutes would be longer than SWW_MAX_BAN_DURATION: the ban is
|
// 90 minutes would be longer than SWWAF_MAX_BAN_DURATION: the ban is
|
||||||
// permanent.
|
// permanent.
|
||||||
clk.advance(30 * time.Minute)
|
clk.advance(30 * time.Minute)
|
||||||
|
|
||||||
@@ -113,7 +111,7 @@ func TestBanLengthsFollowTheSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clk.advance(365 * 24 * time.Hour)
|
clk.advance(365 * 24 * time.Hour)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionPermanentlyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
||||||
@@ -125,10 +123,10 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
|||||||
// client for an hour.
|
// client for an hour.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
for range 3 {
|
for range 3 {
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Later the same day the client has its whole allowance again: the
|
// Later the same day the client has its whole allowance again: the
|
||||||
@@ -137,7 +135,7 @@ func TestBanIsNotCountedAndResetsTheCounters(t *testing.T) {
|
|||||||
clk.advance(time.Hour)
|
clk.advance(time.Hour)
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
banned := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
banned := server.Ledger.Bans(netip.MustParsePrefix(client + "/32"))
|
||||||
if len(banned) != 2 || banned[0].Notes.Refused != 3 {
|
if len(banned) != 2 || banned[0].Notes.Refused != 3 {
|
||||||
@@ -150,8 +148,8 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
|
|
||||||
// In the IPv4 cases, client breaks the limit; these two are next to it.
|
// In the IPv4 cases, client breaks the limit; these two are next to it.
|
||||||
const (
|
const (
|
||||||
allowed = "203.0.113.60" // in SWW_ALLOW_NETS
|
allowed = "203.0.113.60" // in SWWAF_ALLOW_NETS
|
||||||
exempt = "203.0.113.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
exempt = "203.0.113.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -166,7 +164,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
nil, []string{otherClient, exempt},
|
nil, []string{otherClient, exempt},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the IPv4 netblock SWW_BAN_SCOPE_V4_PREFIX sets",
|
"the IPv4 netblock SWWAF_BAN_SCOPE_V4_PREFIX sets",
|
||||||
map[string]string{banScopeV4Prefix: "24"}, client,
|
map[string]string{banScopeV4Prefix: "24"}, client,
|
||||||
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed},
|
[]string{otherClient, exempt}, []string{"203.0.112.9", allowed},
|
||||||
},
|
},
|
||||||
@@ -175,7 +173,7 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
|
[]string{"2001:db8:5::ffff:1"}, []string{"2001:db8:5:1::1"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the IPv6 netblock SWW_IPV6_GROUP_PREFIX sets",
|
"the IPv6 netblock SWWAF_IPV6_GROUP_PREFIX sets",
|
||||||
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
|
map[string]string{ipv6GroupPrefix: "48"}, "2001:db8:7::1",
|
||||||
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
|
[]string{"2001:db8:7:ffff::1"}, []string{"2001:db8:8::1"},
|
||||||
},
|
},
|
||||||
@@ -192,10 +190,10 @@ func TestBanCoversTheClientsNetblock(t *testing.T) {
|
|||||||
s, _, _ := startWithClock(t, "", env)
|
s, _, _ := startWithClock(t, "", env)
|
||||||
|
|
||||||
s.get(tc.breaker, http.StatusOK, requestlog.ActionForward)
|
s.get(tc.breaker, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(tc.breaker, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(tc.breaker, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
for _, sent := range tc.refused {
|
for _, sent := range tc.refused {
|
||||||
s.get(sent, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(sent, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, sent := range tc.let {
|
for _, sent := range tc.let {
|
||||||
@@ -213,14 +211,14 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
banScopeV4Prefix: "24",
|
banScopeV4Prefix: "24",
|
||||||
tagDeny: denyKP,
|
deniedCountries: "kp",
|
||||||
})
|
})
|
||||||
|
|
||||||
// fromDE's ban covers otherClient, which is refused unasked about.
|
// fromDE's ban covers otherClient, which is refused unasked about.
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
if line.Country != "" {
|
if line.Country != "" {
|
||||||
t.Errorf("log line has country %q, want none", line.Country)
|
t.Errorf("log line has country %q, want none", line.Country)
|
||||||
}
|
}
|
||||||
@@ -230,29 +228,13 @@ func TestBannedClientIsRefusedBeforeItsCountryIsLookedUp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRefusalUnderABanNamesItsHeuristicNetblockAndEnd(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
|
||||||
rateLimitPerMinute: "1", banScopeV4Prefix: "24",
|
|
||||||
})
|
|
||||||
expires := requestlog.FormatTime(clk.Now().Add(time.Hour))
|
|
||||||
|
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
|
||||||
|
|
||||||
line := s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
|
||||||
wantHeuristic(t, line, "active_ban",
|
|
||||||
`{"netblock": "`+clientsNet+`", "ban_expires": "`+expires+`"}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting string // "" leaves SWW_BAN_RESPONSE at its default
|
setting string // "" leaves SWWAF_BAN_RESPONSE at its default
|
||||||
status int // 0 is the connection closed without an answer
|
status int // 0 is the connection closed without an answer
|
||||||
}{
|
}{
|
||||||
{"", http.StatusForbidden},
|
{"", http.StatusForbidden},
|
||||||
@@ -268,7 +250,7 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
tagDeny: denyKP,
|
deniedCountries: "kp",
|
||||||
}
|
}
|
||||||
|
|
||||||
if tc.setting != "" {
|
if tc.setting != "" {
|
||||||
@@ -277,11 +259,11 @@ func TestBanResponseAnswersEveryRefusalButTheSizeLimits(t *testing.T) {
|
|||||||
|
|
||||||
s, _, _ := startWithClock(t, geojsURL, env)
|
s, _, _ := startWithClock(t, geojsURL, env)
|
||||||
|
|
||||||
s.get(denied, tc.status, requestlog.ActionClientDenied)
|
s.get(denied, tc.status, requestlog.ActionDenied)
|
||||||
s.get(fromKP, tc.status, requestlog.ActionClientDenied)
|
s.get(fromKP, tc.status, requestlog.ActionCountryDenied)
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(fromDE, tc.status, requestlog.ActionClientIsRateLimited)
|
s.get(fromDE, tc.status, requestlog.ActionRateLimited)
|
||||||
s.get(fromDE, tc.status, requestlog.ActionTemporarilyBanned)
|
s.get(fromDE, tc.status, requestlog.ActionBanned)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,15 +275,15 @@ func TestBanNotes(t *testing.T) {
|
|||||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
tagDeny: denyKP,
|
deniedCountries: "kp",
|
||||||
})
|
})
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
s.request(fromDE, "/repo/commits?page=2",
|
s.request(fromDE, "/repo/commits?page=2",
|
||||||
http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
netblock := netip.MustParsePrefix(fromDE + "/32")
|
netblock := netip.MustParsePrefix(fromDE + "/32")
|
||||||
want := bans.Ban{
|
want := bans.Ban{
|
||||||
@@ -344,7 +326,7 @@ func TestBanNotes(t *testing.T) {
|
|||||||
// The next ban counts this one among the earlier.
|
// The next ban counts this one among the earlier.
|
||||||
clk.advance(time.Hour)
|
clk.advance(time.Hour)
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
got = ledger.Bans(netblock)
|
got = ledger.Bans(netblock)
|
||||||
if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
if len(got) != 2 || got[1].Notes.EarlierBans != (bans.EarlierBans{Limit: 1}) {
|
||||||
@@ -362,15 +344,15 @@ func TestMaxBansDropsTheBanOfTheNetblockSeenLongestAgo(t *testing.T) {
|
|||||||
|
|
||||||
// One ban is held, so otherClient's ban drops client's.
|
// One ban is held, so otherClient's ban drops client's.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(client, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(otherClient, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(otherClient, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
// clock is the time a test sets, by which sww counts requests and
|
// clock is the time a test sets, by which smallwebwaf counts requests and
|
||||||
// makes bans.
|
// makes bans.
|
||||||
type clock struct {
|
type clock struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -393,14 +375,14 @@ func (c *clock) advance(d time.Duration) {
|
|||||||
c.now = c.now.Add(d)
|
c.now = c.now.Add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startWithClock starts discriminator, and rproxy in front of an app that answers 200,
|
// startWithClock starts servewaf, and rproxy in front of an app that answers 200,
|
||||||
// with the settings in env on top of trusting localhost's
|
// with the settings in env on top of trusting localhost's
|
||||||
// X-Forwarded-For, clients' AS numbers and countries looked up at
|
// X-Forwarded-For, clients' AS numbers and countries looked up at
|
||||||
// geojsURL, and a clock set to midnight, the start of a bucket in every
|
// geojsURL, and a clock set to midnight, the start of a bucket in every
|
||||||
// window.
|
// window.
|
||||||
func startWithClock(
|
func startWithClock(
|
||||||
t *testing.T, geojsURL string, env map[string]string,
|
t *testing.T, geojsURL string, env map[string]string,
|
||||||
) (*sender, *clock, *discriminator.Server) {
|
) (*sender, *clock, *servewaf.Server) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
@@ -410,20 +392,19 @@ func startWithClock(
|
|||||||
|
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings)
|
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now, settings)
|
||||||
|
|
||||||
return &sender{t: t, addr: addr, discriminatorAddr: server.Addr, out: out}, clk, server
|
return &sender{t: t, addr: addr, serveWAFAddr: server.Addr, out: out}, clk, server
|
||||||
}
|
}
|
||||||
|
|
||||||
// sender sends requests to rproxy one after another, each on a connection of its
|
// sender sends requests to rproxy one after another, each on a connection of its
|
||||||
// own, and checks each one's answer and log line. discriminatorAddr is where
|
// own, and checks each one's answer and log line. serveWAFAddr is where servewaf's
|
||||||
// discriminator's own endpoints are, which adminRequest asks. Every request
|
// own endpoints are, which adminRequest asks. Every request servewaf gets must
|
||||||
// discriminator gets must come through the sender, since the log lines are
|
// come through the sender, since the log lines are matched to them in order.
|
||||||
// matched to them in order.
|
|
||||||
type sender struct {
|
type sender struct {
|
||||||
t *testing.T
|
t *testing.T
|
||||||
addr string
|
addr string
|
||||||
discriminatorAddr string
|
serveWAFAddr string
|
||||||
out *output
|
out *output
|
||||||
sent int
|
sent int
|
||||||
}
|
}
|
||||||
|
|
||||||
// get sends a GET request for / from the client at from.
|
// get sends a GET request for / from the client at from.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package servewaf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// whole is the percentage of each limit a client gets when no biased
|
||||||
|
// threshold lowers its limits.
|
||||||
|
const whole = 100
|
||||||
|
|
||||||
|
// percentage is a client's limit percentage for the rate limits or for
|
||||||
|
// the byte limits, as the biased thresholds give it, and the setting that
|
||||||
|
// gave it: "" with whole when none lowers that kind of limit.
|
||||||
|
type percentage struct {
|
||||||
|
percent int64
|
||||||
|
setting string
|
||||||
|
}
|
||||||
|
|
||||||
|
// biasedThresholdsSet reports whether a biased threshold can lower a
|
||||||
|
// client's limits: one of its lists is not empty,
|
||||||
|
// SWWAF_UNKNOWN_LIMIT_PERCENT is below 100, or SWWAF_ASN_LIMIT_PERCENT_URL
|
||||||
|
// is set. The client's lookup is then needed before its request goes on.
|
||||||
|
func biasedThresholdsSet(cfg *config.Config) bool {
|
||||||
|
return len(cfg.ASNLimitPercent) > 0 || len(cfg.CountryLimitPercent) > 0 ||
|
||||||
|
len(cfg.ASNBytesPercent) > 0 || len(cfg.CountryBytesPercent) > 0 ||
|
||||||
|
cfg.UnknownLimitPercent < whole || cfg.ASNLimitPercentURL != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitPercentages returns the client's limit percentages, for the rate
|
||||||
|
// limits and for the byte limits, by its AS number and country as looked
|
||||||
|
// up, each "" when unknown, and the blocklists, DNSBL zones and AbuseIPDB
|
||||||
|
// that list it. Each is the lowest of those the settings give it, the
|
||||||
|
// first of them in the order below when several are lowest: the
|
||||||
|
// percentage SWWAF_ASN_LIMIT_PERCENT gives its AS number, the one the file
|
||||||
|
// SWWAF_ASN_LIMIT_PERCENT_URL names gives it, the one
|
||||||
|
// SWWAF_COUNTRY_LIMIT_PERCENT gives its country, for a client without a
|
||||||
|
// country, unless its request skipped the wait for GeoJS,
|
||||||
|
// SWWAF_UNKNOWN_LIMIT_PERCENT, for a client a blocklist lists, the
|
||||||
|
// percentage of SWWAF_BLOCKLIST_ACTION while it is limit, and for a client
|
||||||
|
// a DNSBL zone's verdict lists, or whose AbuseIPDB score is a hit, the
|
||||||
|
// percentage of SWWAF_REPUTATION_ACTION while it is limit. For the byte
|
||||||
|
// limits, SWWAF_ASN_BYTES_PERCENT and SWWAF_COUNTRY_BYTES_PERCENT take the
|
||||||
|
// place of the first three for an AS number or a country they list.
|
||||||
|
func (rq *request) limitPercentages() (percentage, percentage) {
|
||||||
|
cfg := rq.h.config
|
||||||
|
asn, country := rq.line.ASN, rq.line.Country
|
||||||
|
|
||||||
|
unknown := percentage{percent: whole}
|
||||||
|
if country == "" && !rq.skippedLookupWait {
|
||||||
|
unknown = percentage{cfg.UnknownLimitPercent, "SWWAF_UNKNOWN_LIMIT_PERCENT"}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetched := percentage{percent: whole}
|
||||||
|
if percent, listed := rq.h.lists.ASNLimitPercent(asn); listed {
|
||||||
|
fetched = percentage{percent, "SWWAF_ASN_LIMIT_PERCENT_URL"}
|
||||||
|
}
|
||||||
|
|
||||||
|
blocklisted := percentage{percent: whole}
|
||||||
|
if rq.blocklisted && cfg.BlocklistAction == "limit" {
|
||||||
|
blocklisted = percentage{cfg.BlocklistLimitPercent, "SWWAF_BLOCKLIST_ACTION"}
|
||||||
|
}
|
||||||
|
|
||||||
|
reputationListed := percentage{percent: whole}
|
||||||
|
if (rq.dnsblListed || rq.abuseIPDBHit) && cfg.ReputationAction == "limit" {
|
||||||
|
reputationListed = percentage{cfg.ReputationLimitPercent, "SWWAF_REPUTATION_ACTION"}
|
||||||
|
}
|
||||||
|
|
||||||
|
asnRequests := lowest(given(cfg.ASNLimitPercent, asn, "SWWAF_ASN_LIMIT_PERCENT"),
|
||||||
|
fetched)
|
||||||
|
countryRequests := given(cfg.CountryLimitPercent, country,
|
||||||
|
"SWWAF_COUNTRY_LIMIT_PERCENT")
|
||||||
|
|
||||||
|
asnBytes, countryBytes := asnRequests, countryRequests
|
||||||
|
if _, listed := cfg.ASNBytesPercent[asn]; listed {
|
||||||
|
asnBytes = given(cfg.ASNBytesPercent, asn, "SWWAF_ASN_BYTES_PERCENT")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, listed := cfg.CountryBytesPercent[country]; listed {
|
||||||
|
countryBytes = given(cfg.CountryBytesPercent, country, "SWWAF_COUNTRY_BYTES_PERCENT")
|
||||||
|
}
|
||||||
|
|
||||||
|
return lowest(asnRequests, countryRequests, unknown, blocklisted, reputationListed),
|
||||||
|
lowest(asnBytes, countryBytes, unknown, blocklisted, reputationListed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// given returns the percentage percents, the setting named setting, gives
|
||||||
|
// code, an AS number or a country, or whole when it does not list code.
|
||||||
|
func given(percents map[string]int64, code, setting string) percentage {
|
||||||
|
percent, listed := percents[code]
|
||||||
|
if !listed {
|
||||||
|
return percentage{percent: whole}
|
||||||
|
}
|
||||||
|
|
||||||
|
return percentage{percent, setting}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lowest returns the lowest of percentages below whole, the first of them
|
||||||
|
// when several are lowest, or whole when none is below it.
|
||||||
|
func lowest(percentages ...percentage) percentage {
|
||||||
|
low := percentage{percent: whole}
|
||||||
|
|
||||||
|
for _, p := range percentages {
|
||||||
|
if p.percent < low.percent {
|
||||||
|
low = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return low
|
||||||
|
}
|
||||||
|
|
||||||
|
// logged returns p as the log line and the notes of a ban give it: its
|
||||||
|
// percent and setting, or nil and "" for whole, which they leave out.
|
||||||
|
func (p percentage) logged() (*int64, string) {
|
||||||
|
if p.percent == whole {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return &p.percent, p.setting
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package discriminator_test
|
package servewaf_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -15,17 +15,18 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
"sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The biased thresholds.
|
// The biased thresholds.
|
||||||
const (
|
const (
|
||||||
tagLimitPercent = "SWW_TAG_LIMIT_PERCENT"
|
asnLimitPercent = "SWWAF_ASN_LIMIT_PERCENT"
|
||||||
asnBytesPercent = "SWW_ASN_BYTES_PERCENT"
|
countryLimitPercent = "SWWAF_COUNTRY_LIMIT_PERCENT"
|
||||||
countryBytesPercent = "SWW_COUNTRY_BYTES_PERCENT"
|
asnBytesPercent = "SWWAF_ASN_BYTES_PERCENT"
|
||||||
unknownLimitPercent = "SWW_UNKNOWN_LIMIT_PERCENT"
|
countryBytesPercent = "SWWAF_COUNTRY_BYTES_PERCENT"
|
||||||
|
unknownLimitPercent = "SWWAF_UNKNOWN_LIMIT_PERCENT"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -34,11 +35,6 @@ const (
|
|||||||
asnDEHalf = asnDE + ":50"
|
asnDEHalf = asnDE + ":50"
|
||||||
asnDEQuarter = asnDE + ":25"
|
asnDEQuarter = asnDE + ":25"
|
||||||
countryDEHalf = "de:50"
|
countryDEHalf = "de:50"
|
||||||
// The same, as SWW_TAG_LIMIT_PERCENT names fromDE's tags.
|
|
||||||
asnTagDEHalf = "asn=64496:50"
|
|
||||||
asnTagDEQuarter = "asn=64496:25"
|
|
||||||
countryTagDEHalf = "country=DE:50"
|
|
||||||
countryTagDEQuarter = "country=DE:25"
|
|
||||||
// noCountry is in an AS of its own, AS64500, and in no country.
|
// noCountry is in an AS of its own, AS64500, and in no country.
|
||||||
noCountry = "192.0.2.80"
|
noCountry = "192.0.2.80"
|
||||||
// fourAMinute is the rate limit these tests set: half of it is 2
|
// fourAMinute is the rate limit these tests set: half of it is 2
|
||||||
@@ -58,8 +54,8 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting, value, from string
|
setting, value, from string
|
||||||
}{
|
}{
|
||||||
{tagLimitPercent, asnTagDEHalf, fromDE},
|
{asnLimitPercent, asnDEHalf, fromDE},
|
||||||
{tagLimitPercent, countryTagDEHalf, fromDE},
|
{countryLimitPercent, countryDEHalf, fromDE},
|
||||||
{unknownLimitPercent, "50", unplaced},
|
{unknownLimitPercent, "50", unplaced},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.setting, func(t *testing.T) {
|
t.Run(tc.setting, func(t *testing.T) {
|
||||||
@@ -76,7 +72,7 @@ func TestEachBiasedThresholdLowersTheRateLimits(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{http.StatusOK, requestlog.ActionForward},
|
{http.StatusOK, requestlog.ActionForward},
|
||||||
{http.StatusOK, requestlog.ActionForward},
|
{http.StatusOK, requestlog.ActionForward},
|
||||||
{http.StatusForbidden, requestlog.ActionClientIsRateLimited},
|
{http.StatusForbidden, requestlog.ActionRateLimited},
|
||||||
} {
|
} {
|
||||||
line := s.get(tc.from, sent.status, sent.action)
|
line := s.get(tc.from, sent.status, sent.action)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
@@ -100,8 +96,8 @@ func TestEachBiasedThresholdLowersTheByteLimits(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting, value, from string
|
setting, value, from string
|
||||||
}{
|
}{
|
||||||
{tagLimitPercent, asnTagDEHalf, fromDE},
|
{asnLimitPercent, asnDEHalf, fromDE},
|
||||||
{tagLimitPercent, "Country=de:50", fromDE},
|
{countryLimitPercent, "DE:50", fromDE},
|
||||||
{unknownLimitPercent, "50", unplaced},
|
{unknownLimitPercent, "50", unplaced},
|
||||||
{asnBytesPercent, "as64496:50", fromDE},
|
{asnBytesPercent, "as64496:50", fromDE},
|
||||||
{countryBytesPercent, countryDEHalf, fromDE},
|
{countryBytesPercent, countryDEHalf, fromDE},
|
||||||
@@ -155,13 +151,13 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"raising the byte limits back",
|
"raising the byte limits back",
|
||||||
map[string]string{tagLimitPercent: asnTagDEHalf, asnBytesPercent: asnDE + ":100"},
|
map[string]string{asnLimitPercent: asnDEHalf, asnBytesPercent: asnDE + ":100"},
|
||||||
"50 from " + tagLimitPercent, none, "",
|
"50 from " + asnLimitPercent, none, "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"raising the byte limits back, by country",
|
"raising the byte limits back, by country",
|
||||||
map[string]string{tagLimitPercent: countryTagDEHalf, countryBytesPercent: "de:100"},
|
map[string]string{countryLimitPercent: countryDEHalf, countryBytesPercent: "de:100"},
|
||||||
"50 from " + tagLimitPercent, none, "",
|
"50 from " + countryLimitPercent, none, "",
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -188,19 +184,19 @@ func TestBytesPercentSettingsTakeThePlaceOfTheOthersForByteLimits(t *testing.T)
|
|||||||
func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
func TestZeroPercentIsAZeroAllowance(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
s, _, _ := startWithLookups(t, map[string]string{unknownLimitPercent: "0"})
|
s, _, _ := startWithLookups(t, map[string]string{asnLimitPercent: asnDE + ":0"})
|
||||||
|
|
||||||
// The first request breaks the limit, and bans the client; the log line
|
// The first request breaks the limit, and bans the client; the log line
|
||||||
// gives the 0.
|
// gives the 0.
|
||||||
line := s.get(unplaced, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
line := s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
if line.fields["limit_percent"] != float64(0) ||
|
if line.fields["limit_percent"] != float64(0) ||
|
||||||
line.fields["limit_percent_setting"] != unknownLimitPercent {
|
line.fields["limit_percent_setting"] != asnLimitPercent {
|
||||||
t.Errorf("log line has limit_percent %v from %v, want 0 from %s",
|
t.Errorf("log line has limit_percent %v from %v, want 0 from %s",
|
||||||
line.fields["limit_percent"], line.fields["limit_percent_setting"],
|
line.fields["limit_percent"], line.fields["limit_percent_setting"],
|
||||||
unknownLimitPercent)
|
asnLimitPercent)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(unplaced, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLowestPercentageApplies(t *testing.T) {
|
func TestLowestPercentageApplies(t *testing.T) {
|
||||||
@@ -215,29 +211,29 @@ func TestLowestPercentageApplies(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"the country's",
|
"the country's",
|
||||||
map[string]string{tagLimitPercent: asnTagDEHalf + "," + countryTagDEQuarter},
|
map[string]string{asnLimitPercent: asnDEHalf, countryLimitPercent: "de:25"},
|
||||||
fromDE, "25 from " + tagLimitPercent,
|
fromDE, "25 from " + countryLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the AS number's",
|
"the AS number's",
|
||||||
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEHalf},
|
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: countryDEHalf},
|
||||||
fromDE, "25 from " + tagLimitPercent,
|
fromDE, "25 from " + asnLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"the AS number's, the first of two alike",
|
"the AS number's, the first of two alike",
|
||||||
map[string]string{tagLimitPercent: asnTagDEQuarter + "," + countryTagDEQuarter},
|
map[string]string{asnLimitPercent: asnDEQuarter, countryLimitPercent: "de:25"},
|
||||||
fromDE, "25 from " + tagLimitPercent,
|
fromDE, "25 from " + asnLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"that for a client without a country",
|
"that for a client without a country",
|
||||||
map[string]string{tagLimitPercent: "asn=64500:50", unknownLimitPercent: "25"},
|
map[string]string{asnLimitPercent: "AS64500:50", unknownLimitPercent: "25"},
|
||||||
noCountry, "25 from " + unknownLimitPercent,
|
noCountry, "25 from " + unknownLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// SWW_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
// SWWAF_UNKNOWN_LIMIT_PERCENT is left at its default, 100.
|
||||||
"the AS number's, for a client without a country",
|
"the AS number's, for a client without a country",
|
||||||
map[string]string{tagLimitPercent: "asn=64500:25"},
|
map[string]string{asnLimitPercent: "AS64500:25"},
|
||||||
noCountry, "25 from " + tagLimitPercent,
|
noCountry, "25 from " + asnLimitPercent,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -250,7 +246,7 @@ func TestLowestPercentageApplies(t *testing.T) {
|
|||||||
// A quarter of 4 requests a minute: the second breaks the limit.
|
// A quarter of 4 requests a minute: the second breaks the limit.
|
||||||
s.get(tc.from, http.StatusOK, requestlog.ActionForward)
|
s.get(tc.from, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
line := s.get(tc.from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
line := s.get(tc.from, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
tc.want)
|
tc.want)
|
||||||
})
|
})
|
||||||
@@ -269,7 +265,7 @@ func TestUnknownLimitPercentGivesEveryClientWithoutACountryItsPercentage(t *test
|
|||||||
for _, from := range []string{unplaced, "10.0.0.8"} {
|
for _, from := range []string{unplaced, "10.0.0.8"} {
|
||||||
s.get(from, http.StatusOK, requestlog.ActionForward)
|
s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(from, http.StatusOK, requestlog.ActionForward)
|
s.get(from, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(from, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(from, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
}
|
}
|
||||||
|
|
||||||
// One in a country has the whole limit.
|
// One in a country has the whole limit.
|
||||||
@@ -284,7 +280,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
|||||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||||
// says, with a GeoJS that never answers.
|
// says, with a GeoJS that never answers.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now,
|
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now,
|
||||||
map[string]string{unknownLimitPercent: "0"})
|
map[string]string{unknownLimitPercent: "0"})
|
||||||
|
|
||||||
// Once the time the request waits for its answer is up, the client
|
// Once the time the request waits for its answer is up, the client
|
||||||
@@ -293,7 +289,7 @@ func TestClientWithoutAnAnswerInTimeHasTheUnknownLimitPercent(t *testing.T) {
|
|||||||
serveFromDE(t, server, http.MethodGet, http.NoBody)
|
serveFromDE(t, server, http.MethodGet, http.NoBody)
|
||||||
|
|
||||||
line := out.requestLine(t)
|
line := out.requestLine(t)
|
||||||
wantLine(t, line, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
wantLine(t, line, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
wantPercent(t, "limit_percent", line.LimitPercent, line.LimitPercentSetting,
|
||||||
"0 from "+unknownLimitPercent)
|
"0 from "+unknownLimitPercent)
|
||||||
})
|
})
|
||||||
@@ -308,12 +304,8 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
|||||||
setting, value string
|
setting, value string
|
||||||
waits bool
|
waits bool
|
||||||
}{
|
}{
|
||||||
{tagLimitPercent, asnTagDEHalf, true},
|
{asnLimitPercent, asnDEHalf, true},
|
||||||
{tagLimitPercent, countryTagDEHalf, true},
|
{countryLimitPercent, countryDEHalf, true},
|
||||||
{tagDeny, denyKP, true},
|
|
||||||
// A tag the lookup does not give needs no answer.
|
|
||||||
{tagLimitPercent, "vpn:50", false},
|
|
||||||
{tagDeny, "vpn", false},
|
|
||||||
{asnBytesPercent, asnDEHalf, true},
|
{asnBytesPercent, asnDEHalf, true},
|
||||||
{countryBytesPercent, countryDEHalf, true},
|
{countryBytesPercent, countryDEHalf, true},
|
||||||
{unknownLimitPercent, "99", true},
|
{unknownLimitPercent, "99", true},
|
||||||
@@ -327,10 +319,8 @@ func TestRequestWaitsForItsLookupWhileABiasedThresholdIsSet(t *testing.T) {
|
|||||||
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
// In a synctest bubble, as TestRequestWaitsAsLongAsTheLookupTimeoutSays
|
||||||
// says, with a GeoJS that never answers.
|
// says, with a GeoJS that never answers.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now,
|
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now,
|
||||||
map[string]string{
|
map[string]string{lookupTimeout: timeout.String(), tc.setting: tc.value})
|
||||||
lookupTimeout: timeout.String(), tagLists: "vpn=" + vpnList, tc.setting: tc.value,
|
|
||||||
})
|
|
||||||
began := time.Now()
|
began := time.Now()
|
||||||
|
|
||||||
serveFromDE(t, server, http.MethodPost, strings.NewReader("ab"))
|
serveFromDE(t, server, http.MethodPost, strings.NewReader("ab"))
|
||||||
@@ -373,15 +363,15 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
|
|||||||
{
|
{
|
||||||
// A quarter of 12 requests a minute is 3: the fourth breaks it.
|
// A quarter of 12 requests a minute is 3: the fourth breaks it.
|
||||||
"a rate limit",
|
"a rate limit",
|
||||||
map[string]string{rateLimitPerMinute: "12", tagLimitPercent: asnTagDEQuarter},
|
map[string]string{rateLimitPerMinute: "12", asnLimitPercent: asnDEQuarter},
|
||||||
3, http.StatusForbidden, requestlog.ActionClientIsRateLimited,
|
3, http.StatusForbidden, requestlog.ActionRateLimited,
|
||||||
"requests per minute over the limit of 3", "25 from " + tagLimitPercent,
|
"requests per minute over the limit of 3", "25 from " + asnLimitPercent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// The byte limits' percentage, not the rate limits'.
|
// The byte limits' percentage, not the rate limits'.
|
||||||
"a byte limit",
|
"a byte limit",
|
||||||
map[string]string{
|
map[string]string{
|
||||||
bytesLimitPerMinute: twoUploads, tagLimitPercent: asnTagDEQuarter,
|
bytesLimitPerMinute: twoUploads, asnLimitPercent: asnDEQuarter,
|
||||||
asnBytesPercent: asnDEHalf,
|
asnBytesPercent: asnDEHalf,
|
||||||
},
|
},
|
||||||
0, http.StatusOK, requestlog.ActionForward,
|
0, http.StatusOK, requestlog.ActionForward,
|
||||||
@@ -429,7 +419,7 @@ func TestBanForALoweredLimitGivesThePercentageInItsNotesAndItsAlert(t *testing.T
|
|||||||
// clock.
|
// clock.
|
||||||
func startWithLookups(
|
func startWithLookups(
|
||||||
t *testing.T, env map[string]string,
|
t *testing.T, env map[string]string,
|
||||||
) (*sender, *discriminator.Server, *alerts.Queue) {
|
) (*sender, *servewaf.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
s, _, server, queue := startWithLookupsAndClock(t, env)
|
s, _, server, queue := startWithLookupsAndClock(t, env)
|
||||||
@@ -444,7 +434,7 @@ func startWithLookups(
|
|||||||
// country, and no other address.
|
// country, and no other address.
|
||||||
func startWithLookupsAndClock(
|
func startWithLookupsAndClock(
|
||||||
t *testing.T, env map[string]string,
|
t *testing.T, env map[string]string,
|
||||||
) (*sender, *clock, *discriminator.Server, *alerts.Queue) {
|
) (*sender, *clock, *servewaf.Server, *alerts.Queue) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb")
|
||||||
@@ -473,13 +463,11 @@ func (s *sender) uploadFrom(from string) logLine {
|
|||||||
// serveFromDE hands a question about a request from fromDE with method and body
|
// serveFromDE hands a question about a request from fromDE with method and body
|
||||||
// straight to server's handler, without the network, and returns once it is
|
// straight to server's handler, without the network, and returns once it is
|
||||||
// answered.
|
// answered.
|
||||||
func serveFromDE(
|
func serveFromDE(t *testing.T, server *servewaf.Server, method string, body io.Reader) {
|
||||||
t *testing.T, server *discriminator.Server, method string, body io.Reader,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
|
req := httptest.NewRequestWithContext(t.Context(), method, "/", body)
|
||||||
req.Header.Set(discriminator.ClientHeader, fromDE)
|
req.Header.Set(servewaf.ClientHeader, fromDE)
|
||||||
req.Header.Set(requestIDHeader, "the-id")
|
req.Header.Set(requestIDHeader, "the-id")
|
||||||
|
|
||||||
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
@@ -497,7 +485,7 @@ func wantPercent(t *testing.T, what string, percent *int64, setting, want string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// percentText gives a limit percentage and the setting that gave it as
|
// percentText gives a limit percentage and the setting that gave it as
|
||||||
// text, such as "50 from SWW_TAG_LIMIT_PERCENT", or none when both are
|
// text, such as "50 from SWWAF_ASN_LIMIT_PERCENT", or none when both are
|
||||||
// left out.
|
// left out.
|
||||||
func percentText(percent *int64, setting string) string {
|
func percentText(percent *int64, setting string) string {
|
||||||
switch {
|
switch {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package discriminator_test
|
package servewaf_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -19,13 +19,13 @@ import (
|
|||||||
|
|
||||||
// The byte limit settings.
|
// The byte limit settings.
|
||||||
const (
|
const (
|
||||||
bytesLimitPerMinute = "SWW_BYTES_LIMIT_PER_MINUTE"
|
bytesLimitPerMinute = "SWWAF_BYTES_LIMIT_PER_MINUTE"
|
||||||
bytesLimitPerHour = "SWW_BYTES_LIMIT_PER_HOUR"
|
bytesLimitPerHour = "SWWAF_BYTES_LIMIT_PER_HOUR"
|
||||||
bytesLimitPerDay = "SWW_BYTES_LIMIT_PER_DAY"
|
bytesLimitPerDay = "SWWAF_BYTES_LIMIT_PER_DAY"
|
||||||
bytesCount = "SWW_BYTES_COUNT"
|
bytesCount = "SWWAF_BYTES_COUNT"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The values of SWW_BYTES_COUNT.
|
// The values of SWWAF_BYTES_COUNT.
|
||||||
const (
|
const (
|
||||||
countResponse = "response"
|
countResponse = "response"
|
||||||
countRequest = "request"
|
countRequest = "request"
|
||||||
@@ -40,7 +40,7 @@ const (
|
|||||||
// byteLimit is the byte limit these tests set, as a setting: a request
|
// byteLimit is the byte limit these tests set, as a setting: a request
|
||||||
// with a body and its answer, 100 bytes, go over it.
|
// with a body and its answer, 100 bytes, go over it.
|
||||||
byteLimit = "99"
|
byteLimit = "99"
|
||||||
// minuteBytes is limit_hit for SWW_BYTES_LIMIT_PER_MINUTE.
|
// minuteBytes is limit_hit for SWWAF_BYTES_LIMIT_PER_MINUTE.
|
||||||
minuteBytes = "minute_bytes"
|
minuteBytes = "minute_bytes"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,29 +90,14 @@ func TestEachByteLimitBansOnceTheResponseHasEnded(t *testing.T) {
|
|||||||
line.BanExpires, tc.window, expires)
|
line.BanExpires, tc.window, expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
wantMetric(t, s.scrape(), `sww_rate_limit_hits_total{`+
|
wantMetric(t, s.scrape(), `smallwebwaf_rate_limit_hits_total{`+
|
||||||
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
|
`instance="`+alertInstance+`",kind="bytes",window="`+tc.window+`"}`, 1)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestByteLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _ := startWithAnswers(t, map[string]string{bytesLimitPerMinute: byteLimit})
|
|
||||||
|
|
||||||
line, _ := s.download()
|
|
||||||
wantAfterAnswerHeuristic(t, line, "", "null")
|
|
||||||
|
|
||||||
// Two answers of 70 bytes are over the limit of 99.
|
|
||||||
line, _ = s.download()
|
|
||||||
wantHeuristic(t, line, "", "null")
|
|
||||||
wantAfterAnswerHeuristic(t, line, "byte_limits",
|
|
||||||
`{"window": "minute", "count": 140, "limit": 99}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
|
func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -125,7 +110,7 @@ func TestResponseOverAByteLimitByItselfIsPassedOnWhole(t *testing.T) {
|
|||||||
len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes)
|
len(got.body), got.err, line.LimitHit, answerBytes, minuteBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
|
func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
|
||||||
@@ -147,7 +132,7 @@ func TestBytesOfAnAnswerThatBreaksOffAreCounted(t *testing.T) {
|
|||||||
answerBytes, minuteBytes, expires)
|
answerBytes, minuteBytes, expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
||||||
@@ -176,7 +161,7 @@ func TestWebSocketBytesAreCountedOnceItCloses(t *testing.T) {
|
|||||||
line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted)
|
line.LimitHit, line.Counts.MinuteBytes, minuteBytes, tc.counted)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,7 +203,7 @@ func TestBytesCountSaysWhichBytesCount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,8 +212,8 @@ func TestByteLimitsLeaveOutWhatTheRateLimitsLeaveOut(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.7" // in SWW_ALLOW_NETS
|
allowed = "192.0.2.7" // in SWWAF_ALLOW_NETS
|
||||||
exempt = "192.0.2.10" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.10" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _ := startWithAnswers(t, map[string]string{
|
s, _ := startWithAnswers(t, map[string]string{
|
||||||
@@ -386,7 +371,7 @@ func TestObserveModeLeavesOutTheBytesOfARequestEnforceModeRefuses(t *testing.T)
|
|||||||
// counted, and the byte limit is not broken. Its line gives the bytes
|
// counted, and the byte limit is not broken. Its line gives the bytes
|
||||||
// counted before it.
|
// counted before it.
|
||||||
line := s.upload()
|
line := s.upload()
|
||||||
wantWouldAction(t, line, requestlog.ActionClientIsRateLimited)
|
wantWouldAction(t, line, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
if line.LimitHit != minute || line.Counts.MinuteBytes != bodyBytes+answerBytes {
|
if line.LimitHit != minute || line.Counts.MinuteBytes != bodyBytes+answerBytes {
|
||||||
t.Errorf("log line has limit_hit %q and minute_bytes %v, want minute and %d",
|
t.Errorf("log line has limit_hit %q and minute_bytes %v, want minute and %d",
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package discriminator
|
package servewaf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -43,7 +43,7 @@ func scheme(r *http.Request, isQuestion bool) string {
|
|||||||
|
|
||||||
// clientGroup is the client a request is counted toward: its IPv4
|
// clientGroup is the client a request is counted toward: its IPv4
|
||||||
// address, or its IPv6 group, the netblock its IPv6 address is in of the
|
// address, or its IPv6 group, the netblock its IPv6 address is in of the
|
||||||
// length SWW_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
|
// length SWWAF_IPV6_GROUP_PREFIX sets, a /64 by default, since one abuser
|
||||||
// usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
|
// usually holds a whole /64. An IPv4 address in IPv6 form counts as IPv4.
|
||||||
func (h *handler) clientGroup(addr netip.Addr) netip.Prefix {
|
func (h *handler) clientGroup(addr netip.Addr) netip.Prefix {
|
||||||
addr = addr.Unmap()
|
addr = addr.Unmap()
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
package discriminator
|
package servewaf
|
||||||
|
|
||||||
// The heuristic core_rule_set. It reads SWW_WAF_MODE, SWW_WAF_EXEMPT_PATHS
|
|
||||||
// and SWW_WAF_ANOMALY_THRESHOLD.
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
@@ -13,22 +9,18 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/waf"
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// coreRuleSet inspects the request with the Core Rule Set, unless
|
// checkCoreRuleSet inspects the request with the Core Rule Set, unless
|
||||||
// SWW_WAF_MODE is off or SWW_WAF_EXEMPT_PATHS exempts its path, as
|
// SWWAF_WAF_MODE is off or SWWAF_WAF_EXEMPT_PATHS exempts its path, as
|
||||||
// pathExempt decides, and notes the rules it matched and its score in the
|
// pathExempt decides, and notes the rules it matched and its score in the
|
||||||
// log line, and the rules in the metrics. A score at or over
|
// log line, and the rules in the metrics. A score at or over
|
||||||
// SWW_WAF_ANOMALY_THRESHOLD is a match: it adds the tag exploitsignature,
|
// SWWAF_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert,
|
||||||
// raises the waf_block alert, and in block mode refuses the request with 403,
|
// and in block mode refuses the request, which is an offence its client's
|
||||||
// which is an offence its client's history counts. Its figures are the score
|
// history counts, and so returns ActionWAFBlocked. It returns "" for a
|
||||||
// and the threshold.
|
// request it does not refuse.
|
||||||
type coreRuleSet struct{}
|
func (rq *request) checkCoreRuleSet() string {
|
||||||
|
|
||||||
func (coreRuleSet) name() string { return "core_rule_set" }
|
|
||||||
|
|
||||||
func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
|
||||||
cfg := rq.h.config
|
cfg := rq.h.config
|
||||||
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) {
|
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in, cfg.WAFExemptPaths) {
|
||||||
return nil
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
@@ -48,26 +40,22 @@ func (coreRuleSet) check(_ context.Context, rq *request, _ time.Time) *verdict {
|
|||||||
|
|
||||||
threshold := cfg.WAFAnomalyThreshold
|
threshold := cfg.WAFAnomalyThreshold
|
||||||
if threshold == 0 || result.Score < threshold {
|
if threshold == 0 || result.Score < threshold {
|
||||||
return nil
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
rq.addTag(config.ExploitSignatureTag)
|
|
||||||
rq.alertWAFBlock(result)
|
rq.alertWAFBlock(result)
|
||||||
|
|
||||||
if cfg.WAFMode == config.WAFModeDetect {
|
if cfg.WAFMode == config.WAFModeDetect {
|
||||||
return nil
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
rq.wafBlocked = true
|
rq.wafBlocked = true
|
||||||
|
|
||||||
return &verdict{
|
return requestlog.ActionWAFBlocked
|
||||||
action: requestlog.ActionWAFBlocked,
|
|
||||||
figures: map[string]any{figureScore: result.Score, "threshold": threshold},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// alertWAFBlock raises the waf_block alert for the request, which the Core
|
// alertWAFBlock raises the waf_block alert for the request, which the Core
|
||||||
// Rule Set scored at result, at or over SWW_WAF_ANOMALY_THRESHOLD. Its
|
// Rule Set scored at result, at or over SWWAF_WAF_ANOMALY_THRESHOLD. Its
|
||||||
// detail gives the rule ids, the score, the method and the path with the
|
// detail gives the rule ids, the score, the method and the path with the
|
||||||
// query, and, for a request that is not refused for it, the mode: detect,
|
// query, and, for a request that is not refused for it, the mode: detect,
|
||||||
// or observe in observe mode.
|
// or observe in observe mode.
|
||||||
@@ -94,7 +82,7 @@ func (rq *request) alertWAFBlock(result waf.Result) {
|
|||||||
ASN: rq.line.ASN,
|
ASN: rq.line.ASN,
|
||||||
ASName: rq.line.ASName,
|
ASName: rq.line.ASName,
|
||||||
Country: rq.line.Country,
|
Country: rq.line.Country,
|
||||||
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
|
Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD",
|
||||||
Detail: detail,
|
Detail: detail,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
+19
-65
@@ -1,4 +1,4 @@
|
|||||||
package discriminator_test
|
package servewaf_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
@@ -14,13 +14,13 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The Core Rule Set's settings the tests set, besides SWW_WAF_MODE, and
|
// The Core Rule Set's settings the tests set, besides SWWAF_WAF_MODE, and
|
||||||
// its two modes that inspect requests.
|
// its two modes that inspect requests.
|
||||||
const (
|
const (
|
||||||
wafAnomalyThreshold = "SWW_WAF_ANOMALY_THRESHOLD"
|
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD"
|
||||||
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
|
||||||
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
|
||||||
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
|
||||||
block = "block"
|
block = "block"
|
||||||
detect = "detect"
|
detect = "detect"
|
||||||
)
|
)
|
||||||
@@ -28,7 +28,7 @@ const (
|
|||||||
// formData is the type of a form's body.
|
// formData is the type of a form's body.
|
||||||
const formData = "application/x-www-form-urlencoded"
|
const formData = "application/x-www-form-urlencoded"
|
||||||
|
|
||||||
// sizeLimit is SWW_WAF_BODY_LIMIT in the tests that read bodies, 1K as a
|
// sizeLimit is SWWAF_WAF_BODY_LIMIT in the tests that read bodies, 1K as a
|
||||||
// setting.
|
// setting.
|
||||||
const (
|
const (
|
||||||
sizeLimit = 1 << 10
|
sizeLimit = 1 << 10
|
||||||
@@ -37,7 +37,7 @@ const (
|
|||||||
|
|
||||||
// sqlInjection asks for / with an SQL injection in its query, which only
|
// sqlInjection asks for / with an SQL injection in its query, which only
|
||||||
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
|
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
|
||||||
// SWW_WAF_ANOMALY_THRESHOLD.
|
// SWWAF_WAF_ANOMALY_THRESHOLD.
|
||||||
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
||||||
|
|
||||||
// wantWAF checks the request log line's waf_rule_ids and waf_score, and
|
// wantWAF checks the request log line's waf_rule_ids and waf_score, and
|
||||||
@@ -61,42 +61,6 @@ func wantWAF(t *testing.T, line logLine, score *int, ruleIDs ...int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCoreRuleSetMatchAddsTheExploitSignatureTag(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
mode string
|
|
||||||
status int
|
|
||||||
action string
|
|
||||||
}{
|
|
||||||
{block, block, http.StatusForbidden, requestlog.ActionWAFBlocked},
|
|
||||||
{detect, detect, http.StatusOK, requestlog.ActionForward},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
|
|
||||||
|
|
||||||
wantTags(t, s.request(client, sqlInjection, tc.status, tc.action),
|
|
||||||
"exploitsignature")
|
|
||||||
wantTags(t, s.get(client, http.StatusOK, requestlog.ActionForward))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectedAttackIsRefusedByTagDenyLast(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
|
||||||
wafMode: detect, tagDeny: "exploitsignature",
|
|
||||||
})
|
|
||||||
|
|
||||||
line := s.request(client, sqlInjection, http.StatusForbidden,
|
|
||||||
requestlog.ActionClientDenied)
|
|
||||||
wantHeuristic(t, line, "tag_deny", `{"tag": "exploitsignature"}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) {
|
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -146,16 +110,6 @@ func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *test
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCoreRuleSetRefusalNamesItsHeuristicScoreAndThreshold(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
|
||||||
|
|
||||||
line := s.request(client, sqlInjection, http.StatusForbidden,
|
|
||||||
requestlog.ActionWAFBlocked)
|
|
||||||
wantHeuristic(t, line, "core_rule_set", `{"score": 5, "threshold": 5}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
|
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -171,7 +125,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
) {
|
) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
wafMode: block,
|
wafMode: block,
|
||||||
@@ -180,7 +134,7 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
rulesDir: writeRules(t, testRules),
|
rulesDir: writeRules(t, testRules),
|
||||||
})
|
})
|
||||||
|
|
||||||
// A client in SWW_ALLOW_NETS, and a path SWW_WAF_EXEMPT_PATHS
|
// A client in SWWAF_ALLOW_NETS, and a path SWWAF_WAF_EXEMPT_PATHS
|
||||||
// exempts, are not inspected.
|
// exempts, are not inspected.
|
||||||
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWAF(t, line, nil)
|
wantWAF(t, line, nil)
|
||||||
@@ -196,10 +150,10 @@ func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|||||||
|
|
||||||
// A request a rule file refuses is not inspected.
|
// A request a rule file refuses is not inspected.
|
||||||
line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden,
|
line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden,
|
||||||
requestlog.ActionBlockedByBlockRule)
|
requestlog.ActionRuleBlocked)
|
||||||
wantWAF(t, line, nil)
|
wantWAF(t, line, nil)
|
||||||
|
|
||||||
// With SWW_WAF_MODE off, no request is.
|
// With SWWAF_WAF_MODE off, no request is.
|
||||||
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
|
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
|
||||||
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWAF(t, line, nil)
|
wantWAF(t, line, nil)
|
||||||
@@ -404,7 +358,7 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
|
|||||||
line.LimitHit, line.Offence)
|
line.LimitHit, line.Offence)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
want := ratelimit.Offences{Limit: 1, WAFBlocked: 3}
|
want := ratelimit.Offences{Limit: 1, WAFBlocked: 3}
|
||||||
if offences := historyOf(t, server, client).Offences; offences != want {
|
if offences := historyOf(t, server, client).Offences; offences != want {
|
||||||
@@ -413,10 +367,10 @@ func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T)
|
|||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_waf_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
|
`smallwebwaf_waf_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_offences_total{instance="app",kind="waf_blocked"}`, 3)
|
`smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`, 3)
|
||||||
wantMetric(t, metrics, `sww_requests_total{action="waf_blocked",`+
|
wantMetric(t, metrics, `smallwebwaf_requests_total{action="waf_blocked",`+
|
||||||
`instance="app",status_class="4xx"}`, 3)
|
`instance="app",status_class="4xx"}`, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,9 +394,9 @@ func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T
|
|||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_waf_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
|
`smallwebwaf_waf_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
|
||||||
wantNoSeries(t, metrics,
|
wantNoSeries(t, metrics,
|
||||||
`sww_offences_total{instance="app",kind="waf_blocked"}`)
|
`smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
|
func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
|
||||||
@@ -514,7 +468,7 @@ func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) {
|
|||||||
Host: appHost,
|
Host: appHost,
|
||||||
Client: netip.MustParseAddr(client),
|
Client: netip.MustParseAddr(client),
|
||||||
Netblock: netip.MustParsePrefix(client + "/32"),
|
Netblock: netip.MustParsePrefix(client + "/32"),
|
||||||
Reason: "scored by the Core Rule Set at or over SWW_WAF_ANOMALY_THRESHOLD",
|
Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD",
|
||||||
Detail: detail,
|
Detail: detail,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package servewaf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// countryDenied reports whether the country lists refuse the request, by
|
||||||
|
// the client's country as it was looked up. A client without a country,
|
||||||
|
// or whose country cannot be found, is refused only by
|
||||||
|
// SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES.
|
||||||
|
func (rq *request) countryDenied() bool {
|
||||||
|
denied := rq.h.config.DeniedCountries
|
||||||
|
allowed := rq.h.config.ExclusivelyAllowedCountries
|
||||||
|
country := rq.line.Country
|
||||||
|
|
||||||
|
if slices.Contains(denied, country) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(allowed) > 0 && !slices.Contains(allowed, country)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package discriminator_test
|
package servewaf_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -33,13 +33,13 @@ func TestCountryLists(t *testing.T) {
|
|||||||
env map[string]string
|
env map[string]string
|
||||||
refused []string
|
refused []string
|
||||||
}{
|
}{
|
||||||
|
{"denied", map[string]string{deniedCountries: "kp"}, []string{fromKP}},
|
||||||
{
|
{
|
||||||
"exclusively allowed", map[string]string{allowedCountries: "DE"},
|
"exclusively allowed", map[string]string{allowedCountries: "DE"},
|
||||||
[]string{fromKP, unplaced},
|
[]string{fromKP, unplaced},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"with a country in SWW_TAG_DENY",
|
"both", map[string]string{deniedCountries: "kp", allowedCountries: "de,fr"},
|
||||||
map[string]string{tagDeny: denyKP, allowedCountries: "de,fr"},
|
|
||||||
[]string{fromKP, unplaced},
|
[]string{fromKP, unplaced},
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -90,23 +90,6 @@ func TestCountryLists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCountryRefusalNamesItsHeuristicCountryAndSetting(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
geojsURL, _ := startGeoJS(t)
|
|
||||||
s, _, _ := startWithClock(t, geojsURL, map[string]string{
|
|
||||||
lookupTimeout: "1h", allowedCountries: "de",
|
|
||||||
})
|
|
||||||
|
|
||||||
line := s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied)
|
|
||||||
wantHeuristic(t, line, "countries",
|
|
||||||
`{"country": "KP", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
|
|
||||||
|
|
||||||
line = s.get(unplaced, http.StatusForbidden, requestlog.ActionCountryDenied)
|
|
||||||
wantHeuristic(t, line, "countries",
|
|
||||||
`{"country": "", "setting": "SWW_EXCLUSIVELY_ALLOWED_COUNTRIES"}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
|
func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -117,9 +100,9 @@ func TestCountryRefusalKeepsARequestFromTheApp(t *testing.T) {
|
|||||||
})
|
})
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
addr, out := startProxyWithGeoJS(t, app.URL, geojsURL, map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
allowedCountries: "de",
|
deniedCountries: "kp",
|
||||||
})
|
})
|
||||||
|
|
||||||
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
req := newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body"))
|
||||||
@@ -202,7 +185,7 @@ func TestPrivateAddressIsNeverLookedUp(t *testing.T) {
|
|||||||
env map[string]string
|
env map[string]string
|
||||||
}{
|
}{
|
||||||
{"no setting needs the lookup", nil},
|
{"no setting needs the lookup", nil},
|
||||||
{"a tag setting needs it", map[string]string{tagDeny: denyKP}},
|
{"a country list is set", map[string]string{deniedCountries: "kp"}},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -262,11 +245,11 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
|
|||||||
action string
|
action string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"not in SWW_ALLOW_NETS", "",
|
"not in SWWAF_ALLOW_NETS", "",
|
||||||
http.StatusForbidden, requestlog.ActionCountryDenied,
|
http.StatusForbidden, requestlog.ActionCountryDenied,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"in SWW_ALLOW_NETS", "10.0.0.7,fd00::/8",
|
"in SWWAF_ALLOW_NETS", "10.0.0.7,fd00::/8",
|
||||||
http.StatusOK, requestlog.ActionForward,
|
http.StatusOK, requestlog.ActionForward,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
@@ -296,7 +279,7 @@ func TestExclusiveListRefusesAPrivateAddressUnlessAllowed(t *testing.T) {
|
|||||||
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
|
// startGeoJS starts a stand-in for GeoJS, which places fromDE and fromKP,
|
||||||
// each in an AS of its own, and no other address. It returns its URL, and
|
// each in an AS of its own, and no other address. It returns its URL, and
|
||||||
// what returns the addresses it has been asked about. A test that needs
|
// what returns the addresses it has been asked about. A test that needs
|
||||||
// the stand-in to be asked or to answer sets SWW_LOOKUP_TIMEOUT to an
|
// the stand-in to be asked or to answer sets SWWAF_LOOKUP_TIMEOUT to an
|
||||||
// hour, whether or not a request waits for the answer: on the default, a
|
// hour, whether or not a request waits for the answer: on the default, a
|
||||||
// hold-up of the test process can abandon the request to the stand-in, and
|
// hold-up of the test process can abandon the request to the stand-in, and
|
||||||
// leave the client unknown.
|
// leave the client unknown.
|
||||||
@@ -367,7 +350,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// geojsAnswer is an answer of GeoJS about one address, with the fields
|
// geojsAnswer is an answer of GeoJS about one address, with the fields
|
||||||
// sww reads.
|
// smallwebwaf reads.
|
||||||
//
|
//
|
||||||
//nolint:tagliatelle // GeoJS's own names
|
//nolint:tagliatelle // GeoJS's own names
|
||||||
type geojsAnswer struct {
|
type geojsAnswer struct {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package discriminator_test
|
package servewaf_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -9,17 +9,17 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
"sneak.berlin/go/smallwebwaf/internal/reputation"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The CrowdSec settings, and the tests' engine, which is never asked: each
|
// The CrowdSec settings, and the tests' engine, which is never asked: each
|
||||||
// test puts in the copy of its decision list, at decisionsURL, that it
|
// test puts in the copy of its decision list, at decisionsURL, that it
|
||||||
// needs, as reputation.json would at start.
|
// needs, as reputation.json would at start.
|
||||||
const (
|
const (
|
||||||
crowdSecURL = "SWW_CROWDSEC_LAPI_URL"
|
crowdSecURL = "SWWAF_CROWDSEC_LAPI_URL"
|
||||||
crowdSecKey = "SWW_CROWDSEC_LAPI_KEY"
|
crowdSecKey = "SWWAF_CROWDSEC_LAPI_KEY"
|
||||||
lapi = "http://crowdsec.example:8080"
|
lapi = "http://crowdsec.example:8080"
|
||||||
decisionsURL = lapi + "/v1/decisions"
|
decisionsURL = lapi + "/v1/decisions"
|
||||||
bouncerKey = "crowdsec-key-0123456789abcdef"
|
bouncerKey = "crowdsec-key-0123456789abcdef"
|
||||||
@@ -39,7 +39,7 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
|
|||||||
expires := requestlog.FormatTime(fetched.Add(4 * time.Hour))
|
expires := requestlog.FormatTime(fetched.Add(4 * time.Hour))
|
||||||
|
|
||||||
// Its first request is refused, and bans it until the decision ends.
|
// Its first request is refused, and bans it until the decision ends.
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
wantReputation(t, line, decisionsURL)
|
wantReputation(t, line, decisionsURL)
|
||||||
|
|
||||||
if line.BanExpires != expires {
|
if line.BanExpires != expires {
|
||||||
@@ -72,7 +72,7 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
|
|||||||
// ban, and once it has ended the client is let through.
|
// ban, and once it has ended the client is let through.
|
||||||
clk.advance(4*time.Hour - time.Second)
|
clk.advance(4*time.Hour - time.Second)
|
||||||
|
|
||||||
line = s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
line = s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
wantReputation(t, line)
|
wantReputation(t, line)
|
||||||
|
|
||||||
if line.BanExpires != expires {
|
if line.BanExpires != expires {
|
||||||
@@ -87,30 +87,14 @@ func TestClientTheCrowdSecDecisionListListsIsBannedUntilTheDecisionEnds(t *testi
|
|||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}`
|
labels := `{instance="` + alertInstance + `",source="` + decisionsURL + `"}`
|
||||||
|
|
||||||
wantMetric(t, metrics, `sww_bans_made_total{cause="crowdsec",`+
|
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="crowdsec",`+
|
||||||
`instance="`+alertInstance+`"}`, 1)
|
`instance="`+alertInstance+`"}`, 1)
|
||||||
wantMetric(t, metrics, "sww_reputation_hits_total"+labels, 1)
|
wantMetric(t, metrics, "smallwebwaf_reputation_hits_total"+labels, 1)
|
||||||
wantMetric(t, metrics, "sww_reputation_failures_total"+labels, 0)
|
wantMetric(t, metrics, "smallwebwaf_reputation_failures_total"+labels, 0)
|
||||||
wantMetric(t, metrics, "sww_reputation_last_fetch_timestamp_seconds"+labels,
|
wantMetric(t, metrics, "smallwebwaf_reputation_last_fetch_timestamp_seconds"+labels,
|
||||||
float64(fetched.Unix()))
|
float64(fetched.Unix()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCrowdSecBanNamesItsHeuristicScenarioAndEnd(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, clk, server, _ := startWithAlerts(t, map[string]string{
|
|
||||||
crowdSecURL: lapi, crowdSecKey: bouncerKey,
|
|
||||||
})
|
|
||||||
loadDecisions(t, server, clk.Now(), `[{"duration": "4h0m0s", `+
|
|
||||||
`"scenario": "crowdsecurity/ssh-bf", "scope": "Ip", "type": "ban", `+
|
|
||||||
`"value": "`+client+`"}]`)
|
|
||||||
|
|
||||||
line := s.get(client, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
|
||||||
wantHeuristic(t, line, "crowdsec", `{"scenario": "crowdsecurity/ssh-bf", `+
|
|
||||||
`"expires": "`+requestlog.FormatTime(clk.Now().Add(4*time.Hour))+`"}`)
|
|
||||||
wantTags(t, line, "crowdsec")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
|
func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -129,9 +113,9 @@ func TestEndedCrowdSecDecisionNoLongerBansThoughTheCopyStillHoldsIt(t *testing.T
|
|||||||
// Just before its end, the decision bans a client in the netblock, and
|
// Just before its end, the decision bans a client in the netblock, and
|
||||||
// one on an IPv6 address bans the address's group, the /64.
|
// one on an IPv6 address bans the address's group, the /64.
|
||||||
clk.advance(time.Minute - time.Nanosecond)
|
clk.advance(time.Minute - time.Nanosecond)
|
||||||
s.get("198.51.100.7", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get("198.51.100.7", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get("2001:db8::9", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get("2001:db8::5", http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
// Once it has ended, it bans no other client, and the bans it made end
|
// Once it has ended, it bans no other client, and the bans it made end
|
||||||
// with it.
|
// with it.
|
||||||
@@ -164,7 +148,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
|
|||||||
`"value": "`+client+`"}]`)
|
`"value": "`+client+`"}]`)
|
||||||
|
|
||||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
|
wantWouldAction(t, line, requestlog.ActionBanned)
|
||||||
wantReputation(t, line, decisionsURL)
|
wantReputation(t, line, decisionsURL)
|
||||||
|
|
||||||
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
||||||
@@ -184,7 +168,7 @@ func TestObserveModeForwardsAClientTheCrowdSecDecisionListListsAndAlertsTheBan(
|
|||||||
// decisionsURL, answer, the engine's answer, fetched at fetched, as
|
// decisionsURL, answer, the engine's answer, fetched at fetched, as
|
||||||
// reputation.json would at start.
|
// reputation.json would at start.
|
||||||
func loadDecisions(
|
func loadDecisions(
|
||||||
t *testing.T, server *discriminator.Server, fetched time.Time, answer string,
|
t *testing.T, server *servewaf.Server, fetched time.Time, answer string,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
+23
-54
@@ -1,4 +1,4 @@
|
|||||||
package discriminator_test
|
package servewaf_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
"maps"
|
||||||
@@ -10,14 +10,14 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||||
)
|
)
|
||||||
|
|
||||||
const errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
const errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
|
||||||
|
|
||||||
// refused is a request the tests here send, which discriminator refuses after a rule
|
// refused is a request the tests here send, which servewaf refuses after a rule
|
||||||
// file match.
|
// file match.
|
||||||
type refused int
|
type refused int
|
||||||
|
|
||||||
@@ -37,10 +37,9 @@ func (r refused) send(s *sender, from string) logLine {
|
|||||||
switch r {
|
switch r {
|
||||||
case blockRule:
|
case blockRule:
|
||||||
return s.request(from, blockedPath, http.StatusForbidden,
|
return s.request(from, blockedPath, http.StatusForbidden,
|
||||||
requestlog.ActionBlockedByBlockRule)
|
requestlog.ActionRuleBlocked)
|
||||||
case banRule:
|
case banRule:
|
||||||
return s.request(from, probePath, http.StatusForbidden,
|
return s.request(from, probePath, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
requestlog.ActionTemporarilyBanned)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.t.Fatalf("no request for the refusal %d", r)
|
s.t.Fatalf("no request for the refusal %d", r)
|
||||||
@@ -49,10 +48,10 @@ func (r refused) send(s *sender, from string) logLine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startForErrorBurst is startWithClock with testRules, the metrics token and
|
// startForErrorBurst is startWithClock with testRules, the metrics token and
|
||||||
// SWW_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
|
// SWWAF_ERROR_BURST_THRESHOLD at threshold, and the settings in env.
|
||||||
func startForErrorBurst(
|
func startForErrorBurst(
|
||||||
t *testing.T, threshold string, env map[string]string,
|
t *testing.T, threshold string, env map[string]string,
|
||||||
) (*sender, *clock, *discriminator.Server) {
|
) (*sender, *clock, *servewaf.Server) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
settings := map[string]string{
|
settings := map[string]string{
|
||||||
@@ -72,20 +71,9 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
// refusals are four, one over the threshold of three.
|
// refusals are four, one over the threshold of three.
|
||||||
refusals []refused
|
refusals []refused
|
||||||
// nextAction is the action of the client's next request: the next
|
|
||||||
// request after a ban for a ban rule makes it permanent.
|
|
||||||
nextAction string
|
|
||||||
}{
|
}{
|
||||||
{
|
{"block rule", []refused{blockRule, blockRule, blockRule, blockRule}},
|
||||||
"block rule",
|
{"a ban rule last", []refused{blockRule, blockRule, blockRule, banRule}},
|
||||||
[]refused{blockRule, blockRule, blockRule, blockRule},
|
|
||||||
requestlog.ActionTemporarilyBanned,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a ban rule last",
|
|
||||||
[]refused{blockRule, blockRule, blockRule, banRule},
|
|
||||||
requestlog.ActionPermanentlyBanned,
|
|
||||||
},
|
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -113,30 +101,11 @@ func TestErrorBurstBreaksAtOneOverTheThreshold(t *testing.T) {
|
|||||||
"and limit", line.LimitHit, line.Offence)
|
"and limit", line.LimitHit, line.Offence)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(client, http.StatusForbidden, tc.nextAction)
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRefusalLimitBanNamesItsHeuristicWindowCountAndLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, _ := startForErrorBurst(t, "1", nil)
|
|
||||||
|
|
||||||
const ruleFigures = `{"rule_id": "blocked", "rule_action": "block"}`
|
|
||||||
|
|
||||||
line := blockRule.send(s, client)
|
|
||||||
wantHeuristic(t, line, "rule_files", ruleFigures)
|
|
||||||
wantAfterAnswerHeuristic(t, line, "", "null")
|
|
||||||
|
|
||||||
// The second refusal is the request refusal_limit bans for, and the line
|
|
||||||
// names both.
|
|
||||||
line = blockRule.send(s, client)
|
|
||||||
wantHeuristic(t, line, "rule_files", ruleFigures)
|
|
||||||
wantAfterAnswerHeuristic(t, line, "refusal_limit",
|
|
||||||
`{"window": "minute", "count": 2, "limit": 1}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -181,18 +150,18 @@ func TestErrorBurstBanNotesHistoryAndMetrics(t *testing.T) {
|
|||||||
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
t.Fatalf("bans\n%+v\nwant\n%+v", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantOffences := ratelimit.Offences{Limit: 1, BlockedByBlockRule: 3}
|
wantOffences := ratelimit.Offences{Limit: 1, RuleBlocked: 3}
|
||||||
if offences := historyOf(t, server, client).Offences; offences != wantOffences {
|
if offences := historyOf(t, server, client).Offences; offences != wantOffences {
|
||||||
t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences)
|
t.Errorf("history counts the offences %+v, want %+v", offences, wantOffences)
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
|
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
||||||
`kind="refusals",window="minute"}`, 1)
|
`kind="refusals",window="minute"}`, 1)
|
||||||
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_offences_total{instance="app",kind="blocked_by_block_rule"}`, 3)
|
`smallwebwaf_offences_total{instance="app",kind="rule_blocked"}`, 3)
|
||||||
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
||||||
@@ -203,7 +172,7 @@ func TestErrorBurstIsNotLoweredForAClientWithLowerLimits(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
errorBurstThreshold: "2",
|
errorBurstThreshold: "2",
|
||||||
rulesDir: writeRules(t, testRules),
|
rulesDir: writeRules(t, testRules),
|
||||||
tagLimitPercent: countryTagDEHalf,
|
countryLimitPercent: countryDEHalf,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Half of the threshold would be one, which the second refusal is over.
|
// Half of the threshold would be one, which the second refusal is over.
|
||||||
@@ -289,15 +258,15 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
allowed = "192.0.2.60" // in SWW_ALLOW_NETS
|
allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
||||||
exempt = "192.0.2.50" // in SWW_RATE_LIMIT_EXEMPT_NETS
|
exempt = "192.0.2.50" // in SWWAF_RATE_LIMIT_EXEMPT_NETS
|
||||||
)
|
)
|
||||||
|
|
||||||
s, _, _ := startForErrorBurst(t, "1", map[string]string{
|
s, _, _ := startForErrorBurst(t, "1", map[string]string{
|
||||||
allowNets: allowed, rateLimitExemptNets: exempt,
|
allowNets: allowed, rateLimitExemptNets: exempt,
|
||||||
})
|
})
|
||||||
|
|
||||||
// A client in SWW_ALLOW_NETS is not refused, and so not counted.
|
// A client in SWWAF_ALLOW_NETS is not refused, and so not counted.
|
||||||
for range 3 {
|
for range 3 {
|
||||||
s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward)
|
s.request(allowed, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||||
}
|
}
|
||||||
@@ -310,7 +279,7 @@ func TestErrorBurstCountsEachClientTheChecksApplyTo(t *testing.T) {
|
|||||||
t.Errorf("log line has limit_hit %q, want error_burst", line.LimitHit)
|
t.Errorf("log line has limit_hit %q, want error_burst", line.LimitHit)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.get(exempt, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(exempt, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
|
func TestErrorBurstBanSetsTheRefusalsBackToZero(t *testing.T) {
|
||||||
@@ -353,7 +322,7 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
|
|||||||
for range 2 {
|
for range 2 {
|
||||||
line := s.request(otherClient, blockedPath, http.StatusOK,
|
line := s.request(otherClient, blockedPath, http.StatusOK,
|
||||||
requestlog.ActionForward)
|
requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
|
wantWouldAction(t, line, requestlog.ActionBanned)
|
||||||
|
|
||||||
if line.LimitHit != "" {
|
if line.LimitHit != "" {
|
||||||
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
t.Errorf("log line has limit_hit %q, want none", line.LimitHit)
|
||||||
@@ -365,7 +334,7 @@ func TestObserveModeLogsAndAlertsTheErrorBurst(t *testing.T) {
|
|||||||
s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
line := s.request(client, blockedPath, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionBlockedByBlockRule)
|
wantWouldAction(t, line, requestlog.ActionRuleBlocked)
|
||||||
|
|
||||||
if line.LimitHit != requestlog.LimitHitErrorBurst || line.BanExpires != "" {
|
if line.LimitHit != requestlog.LimitHitErrorBurst || line.BanExpires != "" {
|
||||||
t.Errorf("log line has limit_hit %q and ban_expires %q, want error_burst "+
|
t.Errorf("log line has limit_hit %q and ban_expires %q, want error_burst "+
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package discriminator_test
|
package servewaf_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
|
func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
|
||||||
@@ -21,7 +21,7 @@ func TestHealthEndpointCountsTowardNoLimit(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
for range healthChecks {
|
for range healthChecks {
|
||||||
got := s.adminRequest("", http.MethodGet, discriminator.HealthPath, "", http.StatusOK)
|
got := s.adminRequest("", http.MethodGet, servewaf.HealthPath, "", http.StatusOK)
|
||||||
|
|
||||||
if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
|
if string(got.body) != "ok\n" || got.header.Get("Content-Type") != contentType {
|
||||||
t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+
|
t.Errorf("health endpoint answered %q with Content-Type %q, want ok "+
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package discriminator_test
|
package servewaf_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
||||||
@@ -20,7 +20,7 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|||||||
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "2",
|
rateLimitPerMinute: "2",
|
||||||
tagDeny: denyKP,
|
deniedCountries: "kp",
|
||||||
})
|
})
|
||||||
start := clk.Now()
|
start := clk.Now()
|
||||||
|
|
||||||
@@ -31,9 +31,9 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
clk.advance(time.Second)
|
clk.advance(time.Second)
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
||||||
clk.advance(time.Second)
|
clk.advance(time.Second)
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionTemporarilyBanned)
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
||||||
|
|
||||||
want := ratelimit.History{
|
want := ratelimit.History{
|
||||||
FirstSeen: start,
|
FirstSeen: start,
|
||||||
@@ -45,7 +45,7 @@ func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|||||||
Requests: 4,
|
Requests: 4,
|
||||||
Forwarded: 2,
|
Forwarded: 2,
|
||||||
Refused: 2,
|
Refused: 2,
|
||||||
// The app answers with no body, sww with its status text.
|
// The app answers with no body, smallwebwaf with its status text.
|
||||||
ResponseBytes: 2 * int64(len("Forbidden\n")),
|
ResponseBytes: 2 * int64(len("Forbidden\n")),
|
||||||
Responses: ratelimit.Responses{Status2xx: 2, Status4xx: 2},
|
Responses: ratelimit.Responses{Status2xx: 2, Status4xx: 2},
|
||||||
Offences: ratelimit.Offences{Limit: 1},
|
Offences: ratelimit.Offences{Limit: 1},
|
||||||
@@ -100,9 +100,8 @@ func TestOwnEndpointsAreNotInTheHistory(t *testing.T) {
|
|||||||
|
|
||||||
s, _, server := startWithClock(t, "", map[string]string{metricsToken: token})
|
s, _, server := startWithClock(t, "", map[string]string{metricsToken: token})
|
||||||
|
|
||||||
s.adminRequest("", http.MethodGet, discriminator.HealthPath, "", http.StatusOK)
|
s.adminRequest("", http.MethodGet, servewaf.HealthPath, "", http.StatusOK)
|
||||||
s.adminRequest("", http.MethodGet, discriminator.MetricsPath, "",
|
s.adminRequest("", http.MethodGet, servewaf.MetricsPath, "", http.StatusUnauthorized)
|
||||||
http.StatusUnauthorized)
|
|
||||||
s.scrape()
|
s.scrape()
|
||||||
|
|
||||||
if clients := server.Limiter.Snapshot(); len(clients) != 0 {
|
if clients := server.Limiter.Snapshot(); len(clients) != 0 {
|
||||||
@@ -116,7 +115,7 @@ func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) {
|
|||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
|
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
|
||||||
|
|
||||||
wantStatus(t, get(t, addr, "/_sww/nothing"), http.StatusNotFound)
|
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound)
|
||||||
out.endedLine(t)
|
out.endedLine(t)
|
||||||
|
|
||||||
history := historyOf(t, server, localhost)
|
history := historyOf(t, server, localhost)
|
||||||
@@ -127,9 +126,7 @@ func TestPathUnderTheOwnPrefixIsNeitherForwardedNorRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// historyOf returns the history of the client at addr.
|
// historyOf returns the history of the client at addr.
|
||||||
func historyOf(
|
func historyOf(t *testing.T, server *servewaf.Server, addr string) ratelimit.History {
|
||||||
t *testing.T, server *discriminator.Server, addr string,
|
|
||||||
) ratelimit.History {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
client := netip.MustParsePrefix(addr + "/32")
|
client := netip.MustParsePrefix(addr + "/32")
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
package discriminator
|
package servewaf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The headers in which an answer of allow gives rproxy the client's AS number
|
// The headers in which an answer of allow gives rproxy the client's AS number
|
||||||
// and country, for the app, while SWW_ADD_LOOKUP_HEADERS is set. Go writes
|
// and country, for the app, while SWWAF_ADD_LOOKUP_HEADERS is set. Go writes
|
||||||
// every header name in this form, as it sends it and as it receives it, so
|
// every header name in this form, as it sends it and as it receives it, so
|
||||||
// X-Client-ASN arrives as X-Client-Asn; header names are not case-sensitive.
|
// X-Client-ASN arrives as X-Client-Asn; header names are not case-sensitive.
|
||||||
const (
|
const (
|
||||||
@@ -21,9 +19,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// lookUp looks up the client's AS number and country, in the lookup
|
// lookUp looks up the client's AS number and country, in the lookup
|
||||||
// database or through GeoJS, and notes them for the log line and as the
|
// database or through GeoJS, and notes them for the log line, unless
|
||||||
// request's asn= and country= tags, unless
|
// SWWAF_LOOKUP_SOURCE is off or the client is on a private, loopback or
|
||||||
// SWW_LOOKUP_SOURCE is off or the client is on a private, loopback or
|
|
||||||
// link-local address, which no lookup can place. The lookup database
|
// link-local address, which no lookup can place. The lookup database
|
||||||
// answers at once. With GeoJS, while a setting needs the answer, such as a
|
// answers at once. With GeoJS, while a setting needs the answer, such as a
|
||||||
// country list or a biased threshold, a new client's request waits for it,
|
// country list or a biased threshold, a new client's request waits for it,
|
||||||
@@ -50,34 +47,6 @@ func (rq *request) lookUp(ctx context.Context, now time.Time) {
|
|||||||
rq.line.ASN = rq.lookupAnswer.ASN
|
rq.line.ASN = rq.lookupAnswer.ASN
|
||||||
rq.line.ASName = rq.lookupAnswer.ASName
|
rq.line.ASName = rq.lookupAnswer.ASName
|
||||||
rq.line.Country = rq.lookupAnswer.Country
|
rq.line.Country = rq.lookupAnswer.Country
|
||||||
|
|
||||||
if tag := asnTag(rq.line.ASN); tag != "" {
|
|
||||||
rq.addTag(tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tag := countryTag(rq.line.Country); tag != "" {
|
|
||||||
rq.addTag(tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// asnTag returns the tag of asn, an AS number as the lookup gives it, such as
|
|
||||||
// asn=64496 for AS64496, or "" for no AS number.
|
|
||||||
func asnTag(asn string) string {
|
|
||||||
if asn == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return config.ASNTagPrefix + strings.TrimPrefix(asn, "AS")
|
|
||||||
}
|
|
||||||
|
|
||||||
// countryTag returns the tag of country, a code as the lookup gives it, such
|
|
||||||
// as country=DE for DE, or "" for no country.
|
|
||||||
func countryTag(country string) string {
|
|
||||||
if country == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return config.CountryTagPrefix + country
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// addLookup adds answer, an answer about a client from the lookup
|
// addLookup adds answer, an answer about a client from the lookup
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package discriminator_test
|
package servewaf_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
// and country.
|
// and country.
|
||||||
type asnAndCountry struct{ asn, asName, country string }
|
type asnAndCountry struct{ asn, asName, country string }
|
||||||
|
|
||||||
// fileSource is the SWW_LOOKUP_SOURCE that looks clients up in the
|
// fileSource is the SWWAF_LOOKUP_SOURCE that looks clients up in the
|
||||||
// lookup database.
|
// lookup database.
|
||||||
const fileSource = "file"
|
const fileSource = "file"
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ func TestEveryClientIsLookedUpWithoutWaitingWhileNoSettingNeedsIt(t *testing.T)
|
|||||||
// comes too. None waits for GeoJS.
|
// comes too. None waits for GeoJS.
|
||||||
for _, line := range []logLine{
|
for _, line := range []logLine{
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited),
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited),
|
||||||
s.get(fromKP, http.StatusOK, requestlog.ActionForward),
|
s.get(fromKP, http.StatusOK, requestlog.ActionForward),
|
||||||
} {
|
} {
|
||||||
got := asnAndCountry{line.ASN, line.ASName, line.Country}
|
got := asnAndCountry{line.ASN, line.ASName, line.Country}
|
||||||
@@ -94,9 +94,9 @@ func TestRequestThatWaitedForItsLookupIsCountedWhenItGoesOn(t *testing.T) {
|
|||||||
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 58, 0, time.UTC)}
|
clk := &clock{now: time.Date(2026, 10, 6, 0, 0, 58, 0, time.UTC)}
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
|
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, clk.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
tagDeny: denyKP,
|
deniedCountries: "kp",
|
||||||
})
|
})
|
||||||
|
|
||||||
requestFromDE := func() *http.Request {
|
requestFromDE := func() *http.Request {
|
||||||
@@ -162,7 +162,7 @@ func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(t *testing.
|
|||||||
addr, out, server, queue := startProxyWithAlerts(t, app.URL, geojsURL, clk.Now,
|
addr, out, server, queue := startProxyWithAlerts(t, app.URL, geojsURL, clk.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
alertWebhookURL: "https://alerts.example/sww",
|
alertWebhookURL: "https://alerts.example/smallwebwaf",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
})
|
})
|
||||||
s := &sender{t: t, addr: addr, out: out}
|
s := &sender{t: t, addr: addr, out: out}
|
||||||
@@ -179,7 +179,7 @@ func TestASNumberAndNameInTheLogLineTheHistoryTheBanNotesAndTheAlert(t *testing.
|
|||||||
|
|
||||||
for _, line := range []logLine{
|
for _, line := range []logLine{
|
||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited),
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited),
|
||||||
} {
|
} {
|
||||||
if got := (asnAndCountry{line.ASN, line.ASName, line.Country}); got != want {
|
if got := (asnAndCountry{line.ASN, line.ASName, line.Country}); got != want {
|
||||||
t.Errorf("log line has %+v, want %+v", got, want)
|
t.Errorf("log line has %+v, want %+v", got, want)
|
||||||
@@ -264,7 +264,7 @@ func TestClientsAreLookedUpInTheLookupDatabaseAndGeoJSIsNotAsked(t *testing.T) {
|
|||||||
want asnAndCountry
|
want asnAndCountry
|
||||||
}{
|
}{
|
||||||
{s.get(fromDE, http.StatusOK, requestlog.ActionForward), de},
|
{s.get(fromDE, http.StatusOK, requestlog.ActionForward), de},
|
||||||
{s.get(fromDE, http.StatusForbidden, requestlog.ActionClientIsRateLimited), de},
|
{s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited), de},
|
||||||
{
|
{
|
||||||
s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
|
s.get(fromKP, http.StatusForbidden, requestlog.ActionCountryDenied),
|
||||||
asnAndCountry{asnKP, asNameKP, "KP"},
|
asnAndCountry{asnKP, asNameKP, "KP"},
|
||||||
@@ -379,13 +379,13 @@ func TestRequestWaitsAsLongAsTheLookupTimeoutSays(t *testing.T) {
|
|||||||
// clock of the test's own: the wait lasts exactly as long as it should,
|
// clock of the test's own: the wait lasts exactly as long as it should,
|
||||||
// however slowly the test process runs. Nothing in it may wait on the
|
// however slowly the test process runs. Nothing in it may wait on the
|
||||||
// network, which would keep that clock from moving on: the request is
|
// network, which would keep that clock from moving on: the request is
|
||||||
// handed to discriminator's handler, and GeoJS is one that never answers.
|
// handed to servewaf's handler, and GeoJS is one that never answers.
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
// Not the default. The exclusive list needs the answer, and the app
|
// Not the default. The exclusive list needs the answer, and the app
|
||||||
// is never reached.
|
// is never reached.
|
||||||
const timeout = 3 * time.Second
|
const timeout = 3 * time.Second
|
||||||
|
|
||||||
server, out, _ := newDiscriminator(t, unansweredGeoJSURL, time.Now,
|
server, out, _ := newServeWAF(t, unansweredGeoJSURL, time.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
lookupTimeout: timeout.String(),
|
lookupTimeout: timeout.String(),
|
||||||
allowedCountries: "DE",
|
allowedCountries: "DE",
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package discriminator_test
|
package servewaf_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
@@ -11,15 +11,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
metricsToken = "SWW_METRICS_TOKEN" //nolint:gosec // the setting's name
|
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||||
metricsTopN = "SWW_METRICS_TOP_N"
|
metricsTopN = "SWWAF_METRICS_TOP_N"
|
||||||
// token is the SWW_METRICS_TOKEN the tests set, and bearer how a
|
// token is the SWWAF_METRICS_TOKEN the tests set, and bearer how a
|
||||||
// request carries it.
|
// request carries it.
|
||||||
token = "0123456789abcdef0123456789abcdef"
|
token = "0123456789abcdef0123456789abcdef"
|
||||||
bearer = "Bearer " + token
|
bearer = "Bearer " + token
|
||||||
@@ -32,7 +32,7 @@ func TestMetricsAreOffWhileTheTokenIsUnset(t *testing.T) {
|
|||||||
|
|
||||||
// An empty token does not match the unset one either.
|
// An empty token does not match the unset one either.
|
||||||
for _, authorization := range []string{bearer, "Bearer ", ""} {
|
for _, authorization := range []string{bearer, "Bearer ", ""} {
|
||||||
s.adminRequest(authorization, http.MethodGet, discriminator.MetricsPath, "",
|
s.adminRequest(authorization, http.MethodGet, servewaf.MetricsPath, "",
|
||||||
http.StatusNotFound)
|
http.StatusNotFound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,19 +46,18 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
|
|||||||
method, path, authorization string
|
method, path, authorization string
|
||||||
status int
|
status int
|
||||||
}{
|
}{
|
||||||
{http.MethodGet, discriminator.MetricsPath, "", http.StatusUnauthorized},
|
{http.MethodGet, servewaf.MetricsPath, "", http.StatusUnauthorized},
|
||||||
{
|
{
|
||||||
http.MethodGet, discriminator.MetricsPath, "Bearer " + strings.ToUpper(token),
|
http.MethodGet, servewaf.MetricsPath, "Bearer " + strings.ToUpper(token),
|
||||||
http.StatusUnauthorized,
|
http.StatusUnauthorized,
|
||||||
},
|
},
|
||||||
{http.MethodGet, discriminator.MetricsPath, "Basic " + token,
|
{http.MethodGet, servewaf.MetricsPath, "Basic " + token, http.StatusUnauthorized},
|
||||||
http.StatusUnauthorized},
|
{http.MethodGet, servewaf.MetricsPath, bearer, http.StatusOK},
|
||||||
{http.MethodGet, discriminator.MetricsPath, bearer, http.StatusOK},
|
{http.MethodGet, servewaf.MetricsPath, "bearer " + token, http.StatusOK},
|
||||||
{http.MethodGet, discriminator.MetricsPath, "bearer " + token, http.StatusOK},
|
{http.MethodPost, servewaf.MetricsPath, bearer, http.StatusNotFound},
|
||||||
{http.MethodPost, discriminator.MetricsPath, bearer, http.StatusNotFound},
|
{http.MethodGet, servewaf.MetricsPath + "/", bearer, http.StatusNotFound},
|
||||||
{http.MethodGet, discriminator.MetricsPath + "/", bearer, http.StatusNotFound},
|
{http.MethodGet, "/_smallwebwaf/bans", bearer, http.StatusNotFound},
|
||||||
{http.MethodGet, "/_sww/bans", bearer, http.StatusNotFound},
|
{http.MethodPost, servewaf.HealthPath, "", http.StatusNotFound},
|
||||||
{http.MethodPost, discriminator.HealthPath, "", http.StatusNotFound},
|
|
||||||
} {
|
} {
|
||||||
got := s.adminRequest(tc.authorization, tc.method, tc.path, "", tc.status)
|
got := s.adminRequest(tc.authorization, tc.method, tc.path, "", tc.status)
|
||||||
|
|
||||||
@@ -69,7 +68,7 @@ func TestMetricsNeedTheTokenAndOtherPathsAreNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if tc.status == http.StatusOK &&
|
if tc.status == http.StatusOK &&
|
||||||
!strings.Contains(string(got.body), "# TYPE sww_requests_total counter") {
|
!strings.Contains(string(got.body), "# TYPE smallwebwaf_requests_total counter") {
|
||||||
t.Errorf("the metrics are\n%s", got.body)
|
t.Errorf("the metrics are\n%s", got.body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,7 +86,7 @@ func TestMetricsCountTheTraffic(t *testing.T) {
|
|||||||
|
|
||||||
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
|
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
|
||||||
wantStatus(t, got, http.StatusOK)
|
wantStatus(t, got, http.StatusOK)
|
||||||
wantStatus(t, get(t, addr, "/_sww/nothing"), http.StatusNotFound)
|
wantStatus(t, get(t, addr, "/_smallwebwaf/nothing"), http.StatusNotFound)
|
||||||
out.endedLines(t, 2)
|
out.endedLines(t, 2)
|
||||||
|
|
||||||
forward := `{action="forward",instance="app",status_class="2xx"}`
|
forward := `{action="forward",instance="app",status_class="2xx"}`
|
||||||
@@ -95,17 +94,17 @@ func TestMetricsCountTheTraffic(t *testing.T) {
|
|||||||
|
|
||||||
// The request for the metrics is itself under way.
|
// The request for the metrics is itself under way.
|
||||||
metrics := scrape(t, server.Addr)
|
metrics := scrape(t, server.Addr)
|
||||||
wantMetric(t, metrics, "sww_requests_total"+forward, 1)
|
wantMetric(t, metrics, "smallwebwaf_requests_total"+forward, 1)
|
||||||
wantMetric(t, metrics, "sww_requests_total"+notFound, 1)
|
wantMetric(t, metrics, "smallwebwaf_requests_total"+notFound, 1)
|
||||||
wantMetric(t, metrics, "sww_request_bytes_total"+forward, 3)
|
wantMetric(t, metrics, "smallwebwaf_request_bytes_total"+forward, 3)
|
||||||
wantMetric(t, metrics, "sww_response_bytes_total"+forward, 5)
|
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+forward, 5)
|
||||||
wantMetric(t, metrics, "sww_response_bytes_total"+notFound,
|
wantMetric(t, metrics, "smallwebwaf_response_bytes_total"+notFound,
|
||||||
float64(len("Not Found\n")))
|
float64(len("Not Found\n")))
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_request_duration_seconds_count{instance="app"}`, 2)
|
`smallwebwaf_request_duration_seconds_count{instance="app"}`, 2)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_upstream_duration_seconds_count{instance="app"}`, 1)
|
`smallwebwaf_upstream_duration_seconds_count{instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `sww_requests_in_flight{instance="app"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_requests_in_flight{instance="app"}`, 1)
|
||||||
metric(t, metrics, `go_goroutines{instance="app"}`)
|
metric(t, metrics, `go_goroutines{instance="app"}`)
|
||||||
metric(t, metrics, `process_start_time_seconds{instance="app"}`)
|
metric(t, metrics, `process_start_time_seconds{instance="app"}`)
|
||||||
}
|
}
|
||||||
@@ -113,7 +112,7 @@ func TestMetricsCountTheTraffic(t *testing.T) {
|
|||||||
func TestMetricsCountLimitsAndBans(t *testing.T) {
|
func TestMetricsCountLimitsAndBans(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const denied = "192.0.2.50" // in SWW_DENY_NETS
|
const denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
||||||
|
|
||||||
s, clk, _ := startWithClock(t, "", map[string]string{
|
s, clk, _ := startWithClock(t, "", map[string]string{
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
@@ -124,40 +123,40 @@ func TestMetricsCountLimitsAndBans(t *testing.T) {
|
|||||||
maxBanDuration: "2h",
|
maxBanDuration: "2h",
|
||||||
})
|
})
|
||||||
|
|
||||||
// SWW_BAN_RESPONSE=close sends no status at all.
|
// SWWAF_BAN_RESPONSE=close sends no status at all.
|
||||||
s.get(denied, 0, requestlog.ActionClientDenied)
|
s.get(denied, 0, requestlog.ActionDenied)
|
||||||
|
|
||||||
// A first broken limit bans for an hour.
|
// A first broken limit bans for an hour.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, 0, requestlog.ActionClientIsRateLimited)
|
s.get(client, 0, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_requests_total{action="client_denied",instance="app",status_class="none"}`, 1)
|
`smallwebwaf_requests_total{action="denied",instance="app",status_class="none"}`, 1)
|
||||||
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
|
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
||||||
`kind="requests",window="minute"}`, 1)
|
`kind="requests",window="minute"}`, 1)
|
||||||
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 1)
|
||||||
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `sww_active_bans{instance="app"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 0)
|
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 0)
|
||||||
|
|
||||||
clk.advance(time.Hour)
|
clk.advance(time.Hour)
|
||||||
wantMetric(t, s.scrape(), `sww_active_bans{instance="app"}`, 0)
|
wantMetric(t, s.scrape(), `smallwebwaf_active_bans{instance="app"}`, 0)
|
||||||
|
|
||||||
// A limit broken again right after would ban for three hours, longer
|
// A limit broken again right after would ban for three hours, longer
|
||||||
// than SWW_MAX_BAN_DURATION, so the ban is permanent.
|
// than SWWAF_MAX_BAN_DURATION, so the ban is permanent.
|
||||||
s.get(client, http.StatusOK, requestlog.ActionForward)
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
s.get(client, 0, requestlog.ActionClientIsRateLimited)
|
s.get(client, 0, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
metrics = s.scrape()
|
metrics = s.scrape()
|
||||||
wantMetric(t, metrics, `sww_rate_limit_hits_total{instance="app",`+
|
wantMetric(t, metrics, `smallwebwaf_rate_limit_hits_total{instance="app",`+
|
||||||
`kind="requests",window="minute"}`, 2)
|
`kind="requests",window="minute"}`, 2)
|
||||||
wantMetric(t, metrics, `sww_offences_total{instance="app",kind="limit"}`, 2)
|
wantMetric(t, metrics, `smallwebwaf_offences_total{instance="app",kind="limit"}`, 2)
|
||||||
wantMetric(t, metrics, `sww_bans_made_total{cause="limit",instance="app"}`, 2)
|
wantMetric(t, metrics, `smallwebwaf_bans_made_total{cause="limit",instance="app"}`, 2)
|
||||||
wantMetric(t, metrics, `sww_active_bans{instance="app"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_active_bans{instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `sww_permanent_bans{instance="app"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_permanent_bans{instance="app"}`, 1)
|
||||||
// denied and client.
|
// denied and client.
|
||||||
wantMetric(t, metrics, `sww_tracked_clients{instance="app"}`, 2)
|
wantMetric(t, metrics, `smallwebwaf_tracked_clients{instance="app"}`, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
||||||
@@ -168,7 +167,7 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
|||||||
adminToken: adminSecret,
|
adminToken: adminSecret,
|
||||||
})
|
})
|
||||||
|
|
||||||
const admins = `sww_bans_made_total{cause="admin",instance="app"}`
|
const admins = `smallwebwaf_bans_made_total{cause="admin",instance="app"}`
|
||||||
|
|
||||||
wantMetric(t, s.scrape(), admins, 0)
|
wantMetric(t, s.scrape(), admins, 0)
|
||||||
|
|
||||||
@@ -181,7 +180,7 @@ func TestMetricsCountTheBansAnAdminMakes(t *testing.T) {
|
|||||||
wantMetric(t, s.scrape(), admins, 1)
|
wantMetric(t, s.scrape(), admins, 1)
|
||||||
|
|
||||||
// And a ban made through the endpoint.
|
// And a ban made through the endpoint.
|
||||||
s.admin(http.MethodPost, discriminator.BansPath, banOtherClient, http.StatusOK)
|
s.admin(http.MethodPost, servewaf.BansPath, banOtherClient, http.StatusOK)
|
||||||
wantMetric(t, s.scrape(), admins, 2)
|
wantMetric(t, s.scrape(), admins, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,10 +194,10 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
|||||||
_, _ = io.WriteString(w, "hello")
|
_, _ = io.WriteString(w, "hello")
|
||||||
})
|
})
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
metricsTopN: "2",
|
metricsTopN: "2",
|
||||||
allowedCountries: "de,fr",
|
deniedCountries: "kp",
|
||||||
}
|
}
|
||||||
geojsURL, _ := startGeoJS(t)
|
geojsURL, _ := startGeoJS(t)
|
||||||
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, time.Now, env)
|
addr, out, server := startProxyWithClock(t, app.URL, geojsURL, time.Now, env)
|
||||||
@@ -234,25 +233,25 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
|||||||
lines++
|
lines++
|
||||||
|
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_country_requests_total{country="KP",instance="app"}`, 3)
|
`smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_country_requests_total{country="DE",instance="app"}`, 2)
|
`smallwebwaf_country_requests_total{country="DE",instance="app"}`, 2)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_country_requests_total{country="other",instance="app"}`, 1)
|
`smallwebwaf_country_requests_total{country="other",instance="app"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_country_list_refusals_total{country="KP",instance="app"}`, 3)
|
`smallwebwaf_country_list_refusals_total{country="KP",instance="app"}`, 3)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_country_request_bytes_total{country="KP",instance="app"}`, 0)
|
`smallwebwaf_country_request_bytes_total{country="KP",instance="app"}`, 0)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_country_request_bytes_total{country="DE",instance="app"}`, 6)
|
`smallwebwaf_country_request_bytes_total{country="DE",instance="app"}`, 6)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_country_response_bytes_total{country="KP",instance="app"}`,
|
`smallwebwaf_country_response_bytes_total{country="KP",instance="app"}`,
|
||||||
float64(3*len("Forbidden\n")))
|
float64(3*len("Forbidden\n")))
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_country_response_bytes_total{country="other",instance="app"}`,
|
`smallwebwaf_country_response_bytes_total{country="other",instance="app"}`,
|
||||||
float64(len("hello")))
|
float64(len("hello")))
|
||||||
wantNoSeries(t, metrics,
|
wantNoSeries(t, metrics,
|
||||||
`sww_country_requests_total{country="FR",instance="app"}`)
|
`smallwebwaf_country_requests_total{country="FR",instance="app"}`)
|
||||||
|
|
||||||
// Once FR is busier than DE, it takes DE's place: its series counts
|
// Once FR is busier than DE, it takes DE's place: its series counts
|
||||||
// from then on, and DE's is gone.
|
// from then on, and DE's is gone.
|
||||||
@@ -260,15 +259,15 @@ func TestMetricsByCountryKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
|||||||
|
|
||||||
metrics = scrape(t, server.Addr)
|
metrics = scrape(t, server.Addr)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_country_requests_total{country="KP",instance="app"}`, 3)
|
`smallwebwaf_country_requests_total{country="KP",instance="app"}`, 3)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_country_requests_total{country="FR",instance="app"}`, 2)
|
`smallwebwaf_country_requests_total{country="FR",instance="app"}`, 2)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_country_requests_total{country="other",instance="app"}`, 2)
|
`smallwebwaf_country_requests_total{country="other",instance="app"}`, 2)
|
||||||
wantNoSeries(t, metrics,
|
wantNoSeries(t, metrics,
|
||||||
`sww_country_requests_total{country="DE",instance="app"}`)
|
`smallwebwaf_country_requests_total{country="DE",instance="app"}`)
|
||||||
wantNoSeries(t, metrics,
|
wantNoSeries(t, metrics,
|
||||||
`sww_country_request_bytes_total{country="DE",instance="app"}`)
|
`smallwebwaf_country_request_bytes_total{country="DE",instance="app"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
||||||
@@ -299,15 +298,15 @@ func TestMetricsByASNumberKeepTheBusiestAndCountTheRestAsOther(t *testing.T) {
|
|||||||
|
|
||||||
metrics := s.scrape()
|
metrics := s.scrape()
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_asn_requests_total{asn="AS64501",instance="app"}`, 2)
|
`smallwebwaf_asn_requests_total{asn="AS64501",instance="app"}`, 2)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_asn_requests_total{asn="other",instance="app"}`, 1)
|
`smallwebwaf_asn_requests_total{asn="other",instance="app"}`, 1)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_asn_request_bytes_total{asn="AS64501",instance="app"}`, 0)
|
`smallwebwaf_asn_request_bytes_total{asn="AS64501",instance="app"}`, 0)
|
||||||
wantMetric(t, metrics,
|
wantMetric(t, metrics,
|
||||||
`sww_asn_response_bytes_total{asn="other",instance="app"}`, 0)
|
`smallwebwaf_asn_response_bytes_total{asn="other",instance="app"}`, 0)
|
||||||
wantNoSeries(t, metrics,
|
wantNoSeries(t, metrics,
|
||||||
`sww_asn_requests_total{asn="AS64502",instance="app"}`)
|
`smallwebwaf_asn_requests_total{asn="AS64502",instance="app"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
||||||
@@ -322,32 +321,32 @@ func TestMetricsCountGeoJSRequestsAndFailures(t *testing.T) {
|
|||||||
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
addr, _, server := startProxyWithClock(t, app.URL, geojs.URL, time.Now,
|
addr, _, server := startProxyWithClock(t, app.URL, geojs.URL, time.Now,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
trustedProxies: trustLocalhost,
|
trustedProxies: trustLocalhost,
|
||||||
metricsToken: token,
|
metricsToken: token,
|
||||||
tagDeny: denyKP,
|
deniedCountries: "kp",
|
||||||
})
|
})
|
||||||
|
|
||||||
// GeoJS fails, so the client has no country= tag, and SWW_TAG_DENY does
|
// GeoJS fails, so the client counts as coming from an unknown country,
|
||||||
// not refuse it.
|
// which SWWAF_DENIED_COUNTRIES does not refuse.
|
||||||
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
||||||
req.Header.Set(forwardedFor, fromDE)
|
req.Header.Set(forwardedFor, fromDE)
|
||||||
wantStatus(t, do(t, req), http.StatusOK)
|
wantStatus(t, do(t, req), http.StatusOK)
|
||||||
|
|
||||||
// The client stops waiting for GeoJS once SWW_LOOKUP_TIMEOUT is up, so
|
// The client stops waiting for GeoJS once SWWAF_LOOKUP_TIMEOUT is up, so
|
||||||
// GeoJS's failure can come after its request has ended.
|
// GeoJS's failure can come after its request has ended.
|
||||||
deadline := time.Now().Add(waitLimit)
|
deadline := time.Now().Add(waitLimit)
|
||||||
metrics := scrape(t, server.Addr)
|
metrics := scrape(t, server.Addr)
|
||||||
|
|
||||||
for metric(t, metrics, `sww_geojs_failures_total{instance="app"}`) == 0 &&
|
for metric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`) == 0 &&
|
||||||
time.Now().Before(deadline) {
|
time.Now().Before(deadline) {
|
||||||
time.Sleep(pollInterval)
|
time.Sleep(pollInterval)
|
||||||
|
|
||||||
metrics = scrape(t, server.Addr)
|
metrics = scrape(t, server.Addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantMetric(t, metrics, `sww_geojs_requests_total{instance="app"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_geojs_requests_total{instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `sww_geojs_failures_total{instance="app"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_geojs_failures_total{instance="app"}`, 1)
|
||||||
wantMetric(t, metrics, `sww_geojs_unanswered_total{instance="app"}`, 1)
|
wantMetric(t, metrics, `smallwebwaf_geojs_unanswered_total{instance="app"}`, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// keptAnswer returns GeoJS's answer that the client at addr is in
|
// keptAnswer returns GeoJS's answer that the client at addr is in
|
||||||
@@ -361,12 +360,12 @@ func keptAnswer(addr, country string) lookup.Answer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// scrape asks discriminator at addr for the metrics, with the token, and returns
|
// scrape asks servewaf at addr for the metrics, with the token, and returns
|
||||||
// them.
|
// them.
|
||||||
func scrape(t *testing.T, addr string) string {
|
func scrape(t *testing.T, addr string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
req := newRequest(t, http.MethodGet, addr, discriminator.MetricsPath, http.NoBody)
|
req := newRequest(t, http.MethodGet, addr, servewaf.MetricsPath, http.NoBody)
|
||||||
req.Header.Set("Authorization", bearer)
|
req.Header.Set("Authorization", bearer)
|
||||||
|
|
||||||
got := do(t, req)
|
got := do(t, req)
|
||||||
@@ -381,14 +380,14 @@ func scrape(t *testing.T, addr string) string {
|
|||||||
func (s *sender) scrape() string {
|
func (s *sender) scrape() string {
|
||||||
s.t.Helper()
|
s.t.Helper()
|
||||||
|
|
||||||
return string(s.adminRequest(bearer, http.MethodGet, discriminator.MetricsPath, "",
|
return string(s.adminRequest(bearer, http.MethodGet, servewaf.MetricsPath, "",
|
||||||
http.StatusOK).body)
|
http.StatusOK).body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// metric returns the value of series in metrics, which are in the
|
// metric returns the value of series in metrics, which are in the
|
||||||
// Prometheus text format. series is a name and its labels in the order of
|
// Prometheus text format. series is a name and its labels in the order of
|
||||||
// their names, such as
|
// their names, such as
|
||||||
// sww_offences_total{instance="app",kind="limit"}. It fails the
|
// smallwebwaf_offences_total{instance="app",kind="limit"}. It fails the
|
||||||
// test if there is no such series.
|
// test if there is no such series.
|
||||||
func metric(t *testing.T, metrics, series string) float64 {
|
func metric(t *testing.T, metrics, series string) float64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package discriminator_test
|
package servewaf_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -7,123 +7,23 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// observe is the value of SWW_MODE for observe mode.
|
// observe is the value of SWWAF_MODE for observe mode.
|
||||||
const observe = "observe"
|
const observe = "observe"
|
||||||
|
|
||||||
func TestBanActionSaysWhetherTheBanRunsOut(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const trapPath = "/wp-login.php"
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
cause string
|
|
||||||
// endsIn is how long after the request the ban ends: negative for a
|
|
||||||
// ban that has ended, and zero for a permanent ban.
|
|
||||||
endsIn time.Duration
|
|
||||||
path string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"temporary ban", bans.CauseAdmin, time.Hour, "/",
|
|
||||||
requestlog.ActionTemporarilyBanned,
|
|
||||||
},
|
|
||||||
{"permanent ban", bans.CauseAdmin, 0, "/", requestlog.ActionPermanentlyBanned},
|
|
||||||
{
|
|
||||||
"request that makes a ban permanent", bans.CauseAttack, time.Hour, "/",
|
|
||||||
requestlog.ActionPermanentlyBanned,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"trap path after an attack ban ended", bans.CauseAttack, -time.Hour,
|
|
||||||
trapPath, requestlog.ActionPermanentlyBanned,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"first trap path after another ban ended", bans.CauseAdmin, -time.Hour,
|
|
||||||
trapPath, requestlog.ActionTemporarilyBanned,
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
for _, modeValue := range []string{"enforce", observe} {
|
|
||||||
t.Run(tc.name+" in "+modeValue, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, clk, server := startWithClock(t, "", map[string]string{
|
|
||||||
trapPaths: trapPathList, mode: modeValue,
|
|
||||||
})
|
|
||||||
|
|
||||||
ban := bans.Ban{
|
|
||||||
Netblock: netip.MustParsePrefix(client + "/32"),
|
|
||||||
Start: clk.Now().Add(-2 * time.Hour),
|
|
||||||
Cause: tc.cause,
|
|
||||||
}
|
|
||||||
if tc.endsIn != 0 {
|
|
||||||
ban.Expires = clk.Now().Add(tc.endsIn)
|
|
||||||
}
|
|
||||||
|
|
||||||
server.Ledger.Load([]bans.Ban{ban})
|
|
||||||
|
|
||||||
if modeValue == observe {
|
|
||||||
line := s.request(client, tc.path, http.StatusOK, requestlog.ActionForward)
|
|
||||||
wantWouldAction(t, line, tc.want)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.request(client, tc.path, http.StatusForbidden, tc.want)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestObserveModeNamesAPermanentBanWhoseAlertWouldBeSent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, clk, server, queue := startWithAlerts(t, map[string]string{
|
|
||||||
trapPaths: trapPathList, mode: observe,
|
|
||||||
})
|
|
||||||
|
|
||||||
server.Ledger.Load([]bans.Ban{{
|
|
||||||
Netblock: netip.MustParsePrefix(client + "/32"),
|
|
||||||
Start: clk.Now().Add(-2 * time.Hour),
|
|
||||||
Expires: clk.Now().Add(-time.Hour),
|
|
||||||
Cause: bans.CauseAttack,
|
|
||||||
}})
|
|
||||||
|
|
||||||
line := s.request(client, "/wp-login.php", http.StatusOK, requestlog.ActionForward)
|
|
||||||
wantWouldAction(t, line, requestlog.ActionPermanentlyBanned)
|
|
||||||
|
|
||||||
waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook]
|
|
||||||
if len(waiting) != 1 || waiting[0].Event != alerts.EventPermanentBan {
|
|
||||||
t.Errorf("alerts waiting are %+v, want one %s", waiting, alerts.EventPermanentBan)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestObserveModeNamesTheHeuristicThatWouldHaveRefused(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
|
||||||
mode: observe, denyNets: client,
|
|
||||||
})
|
|
||||||
|
|
||||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
||||||
wantWouldAction(t, line, requestlog.ActionClientDenied)
|
|
||||||
wantHeuristic(t, line, "deny_nets", `{"netblock": "`+client+`/32"}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const (
|
const (
|
||||||
denied = "192.0.2.50" // in SWW_DENY_NETS
|
denied = "192.0.2.50" // in SWWAF_DENY_NETS
|
||||||
banned = otherClient // under a ban read from bans.json
|
banned = otherClient // under a ban read from bans.json
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting string // "" leaves SWW_MODE at its default
|
setting string // "" leaves SWWAF_MODE at its default
|
||||||
observe bool
|
observe bool
|
||||||
}{
|
}{
|
||||||
{"", false},
|
{"", false},
|
||||||
@@ -138,7 +38,7 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
|||||||
lookupTimeout: "1h",
|
lookupTimeout: "1h",
|
||||||
rateLimitPerMinute: "1",
|
rateLimitPerMinute: "1",
|
||||||
denyNets: denied,
|
denyNets: denied,
|
||||||
tagDeny: denyKP,
|
deniedCountries: "kp",
|
||||||
}
|
}
|
||||||
|
|
||||||
if tc.setting != "" {
|
if tc.setting != "" {
|
||||||
@@ -157,10 +57,10 @@ func TestObserveModeForwardsWhatEnforceModeRefuses(t *testing.T) {
|
|||||||
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
||||||
|
|
||||||
for _, sent := range []struct{ from, refusal string }{
|
for _, sent := range []struct{ from, refusal string }{
|
||||||
{denied, requestlog.ActionClientDenied},
|
{denied, requestlog.ActionDenied},
|
||||||
{banned, requestlog.ActionTemporarilyBanned},
|
{banned, requestlog.ActionBanned},
|
||||||
{fromKP, requestlog.ActionClientDenied},
|
{fromKP, requestlog.ActionCountryDenied},
|
||||||
{fromDE, requestlog.ActionClientIsRateLimited},
|
{fromDE, requestlog.ActionRateLimited},
|
||||||
} {
|
} {
|
||||||
if !tc.observe {
|
if !tc.observe {
|
||||||
line := s.get(sent.from, http.StatusForbidden, sent.refusal)
|
line := s.get(sent.from, http.StatusForbidden, sent.refusal)
|
||||||
@@ -198,7 +98,7 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
|
|||||||
|
|
||||||
for range 2 {
|
for range 2 {
|
||||||
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
line := s.get(client, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionClientIsRateLimited)
|
wantWouldAction(t, line, requestlog.ActionRateLimited)
|
||||||
|
|
||||||
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
|
if line.LimitHit != minute || line.Offence != requestlog.OffenceLimit ||
|
||||||
line.BanExpires != "" {
|
line.BanExpires != "" {
|
||||||
@@ -211,7 +111,7 @@ func TestObserveModeMakesNoBanAndKeepsTheBansItHas(t *testing.T) {
|
|||||||
// The ban read from bans.json refuses nothing, and so counts no
|
// The ban read from bans.json refuses nothing, and so counts no
|
||||||
// refusal in its notes, but is kept.
|
// refusal in its notes, but is kept.
|
||||||
line := s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
line := s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
||||||
wantWouldAction(t, line, requestlog.ActionTemporarilyBanned)
|
wantWouldAction(t, line, requestlog.ActionBanned)
|
||||||
|
|
||||||
if line.BanExpires != requestlog.FormatTime(kept.Expires) {
|
if line.BanExpires != requestlog.FormatTime(kept.Expires) {
|
||||||
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires,
|
t.Errorf("log line has ban_expires %q, want %s", line.BanExpires,
|
||||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user