Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot df8c0ebb29 The Core Rule Set, run by Coraza, on each request's method, URL and headers (closes #25)
check / check (push) Waiting to run
Coraza v3.8.1 runs the Core Rule Set 4.25.0 (coraza-coreruleset v4.25.0)
after the rule files, with the six changes and the default
SWWAF_WAF_DISABLED_RULES that SPEC.md gives; no body, no response.
SWWAF_WAF_MODE, SWWAF_WAF_PARANOIA_LEVEL, SWWAF_WAF_ANOMALY_THRESHOLD and
SWWAF_WAF_EXEMPT_PATHS as specified; SWWAF_WAF_DISABLED_RULES refuses
900000 to 900999, smallwebwaf's own rules among them. A request with more
query parameters than Coraza reads, 1000, adds 5 (rule 900300). In block
mode a match is refused with 403, an offence counted toward the error
burst; in detect mode it is let through. Both log waf_rule_ids, waf_score
and duration_waf, raise waf_block, and count
smallwebwaf_waf_matches_total.

Judgement call: waf_block is raised in block mode too.
Deviation: no engine-error path; with no body read, Coraza cannot fail.

Model: opus-5-5
2026-10-08 06:57:17 +00:00
13 changed files with 105 additions and 959 deletions
+5 -9
View File
@@ -36,12 +36,11 @@ RUN go mod tidy -diff || \
{ echo "go.mod or go.sum is not tidy: run make tidy" >&2; exit 1; } { echo "go.mod or go.sum is not tidy: run make tidy" >&2; exit 1; }
# Go's build cache is kept on a tmpfs, out of the image: nothing uses it # Go's build cache is kept on a tmpfs, out of the image: nothing uses it
# after this step, and writing it into the image takes seconds. The tests # after this step, and writing it into the image takes seconds.
# are built with the no_fs_access tag, as the binary is in the build stage.
RUN --mount=type=tmpfs,target=/root/.cache/go-build \ RUN --mount=type=tmpfs,target=/root/.cache/go-build \
go test -tags no_fs_access -timeout 90s -race -cover ./... || \ go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -tags no_fs_access -timeout 90s -race -v ./...; exit 1; } go test -timeout 90s -race -v ./...; exit 1; }
# Tidy stage: `go mod tidy` in the test phase's Go, so that the files it # Tidy stage: `go mod tidy` in the test phase's Go, so that the files it
# writes pass the test phase's check. Nothing else depends on it, so only # writes pass the test phase's check. Nothing else depends on it, so only
@@ -85,10 +84,7 @@ COPY . .
# The VERSION build arg when one is given, otherwise # The VERSION build arg when one is given, otherwise
# `git describe --tags --always` on the .git in the build context. With # `git describe --tags --always` on the .git in the build context. With
# .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.
# tag keeps Coraza from writing the files of a multipart body to the
# system's temporary directory, since smallwebwaf writes only to its state
# directory.
ARG VERSION ARG VERSION
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \ if [ -e .git ]; then \
@@ -97,7 +93,7 @@ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
exit 1 ;; \ exit 1 ;; \
esac; \ esac; \
fi; \ fi; \
CGO_ENABLED=0 go build -tags no_fs_access -trimpath \ CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \ -ldflags="-s -w -X main.Version=${VERSION}" \
-o /usr/local/bin/smallwebwaf ./cmd/smallwebwaf -o /usr/local/bin/smallwebwaf ./cmd/smallwebwaf
+41 -85
View File
@@ -64,10 +64,11 @@ cannot read, serves Prometheus metrics to a scraper that holds the metrics
token, lets an admin who holds the admin token list, add and lift bans and ask token, lets an admin who holds the admin token list, add and lift bans and ask
what it knows of a client, and in `observe` mode passes on the requests it would what it knows of a client, and in `observe` mode passes on the requests it would
refuse, logging what it would have done with them. It comes as the image the refuse, logging what it would have done with them. It comes as the image the
app's own image is built on. The Core Rule Set reads a request's body too once app's own image is built on. The Core Rule Set reads no request body yet:
`SWWAF_WAF_BODY_LIMIT` is set. The rest of the design comes next, in the order `SWWAF_WAF_BODY_LIMIT`, which switches that on, comes with
of the build order in [`SPEC.md`](SPEC.md). The survey of existing tools that https://git.eeqj.de/sneak/smallwebwaf/issues/116. The rest of the design comes
led to the design is in [`EVALUATION.md`](EVALUATION.md). after that, in the order of the build order in [`SPEC.md`](SPEC.md). The survey
of existing tools that led to the design is in [`EVALUATION.md`](EVALUATION.md).
## Getting started ## Getting started
@@ -97,9 +98,7 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
- Passes each request to the app and the app's answer back unchanged: method, - Passes each request to the app and the app's answer back unchanged: method,
path, query, headers, body and status. Bodies stream through in both path, query, headers, body and status. Bodies stream through in both
directions and are never held whole in memory, but for the part of a request directions and are never held whole in memory. A WebSocket, or any other
body the Core Rule Set reads, at most `SWWAF_WAF_BODY_LIMIT` and one byte
more, which is held until the app is sent it. A WebSocket, or any other
upgraded connection, passes through, and the timeouts do not cut it. upgraded connection, passes through, and the timeouts do not cut it.
- Works out the client's address. A TCP peer outside `SWWAF_TRUSTED_PROXIES` is - Works out the client's address. A TCP peer outside `SWWAF_TRUSTED_PROXIES` is
the client, and the forwarded headers it sends are replaced, not passed on. the client, and the forwarded headers it sends are replaced, not passed on.
@@ -204,33 +203,26 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
A client in `SWWAF_ALLOW_NETS` is not checked. A client in `SWWAF_ALLOW_NETS` is not checked.
- Inspects each request with the OWASP Core Rule Set 4.25.0, run by Coraza, - Inspects each request with the OWASP Core Rule Set 4.25.0, run by Coraza,
after the rule files, unless `SWWAF_WAF_MODE` is `off`: its method, its URL after the rule files, unless `SWWAF_WAF_MODE` is `off`: its method, its URL
with the query, and its headers, and, once `SWWAF_WAF_BODY_LIMIT` is set, its with the query, and its headers, but no request body and no response. Each of
body when it is form data, multipart, JSON or XML, as that setting below its rules that matches adds to the request's anomaly score, up to the paranoia
describes; no response is inspected. Each of its rules that matches adds to level `SWWAF_WAF_PARANOIA_LEVEL` sets. A request with more than 1000 query
the request's anomaly score, up to the paranoia level parameters adds 5 (rule 900300), as a rule rated critical does, since Coraza
`SWWAF_WAF_PARANOIA_LEVEL` sets. A request with more than 1000 query reads only the first 1000. A score at or over `SWWAF_WAF_ANOMALY_THRESHOLD`, 5
parameters, or more than 1000 fields in a form data or JSON body it reads, by default, is a match: in `block` mode, the default, the request is refused
adds 5 (rule 900300), as a rule rated critical does, since Coraza reads only with `403`, and in `detect` mode it goes on to the app. Either way its log
the first 1000. A score at or over `SWWAF_WAF_ANOMALY_THRESHOLD`, 5 by line names the rules and the score (see `waf_rule_ids` and `waf_score` in
default, is a match: in `block` mode, the default, the request is refused with "Request log" below), and it raises a `waf_block` alert. A refusal bans no one
`403`, and in `detect` mode it goes on to the app. Either way its log line by itself, since the Core Rule Set takes some ordinary requests for attacks,
names the rules and the score (see `waf_rule_ids` and `waf_score` in "Request but it is an offence the client's history counts, and it counts toward the
log" below), and it raises a `waf_block` alert. A refusal bans no one by error burst; a match in `detect` mode is neither. A request a rule file
itself, since the Core Rule Set takes some ordinary requests for attacks, but refuses, one for a path `SWWAF_WAF_EXEMPT_PATHS` exempts, and one from a
it is an offence the client's history counts, and it counts toward the error client in `SWWAF_ALLOW_NETS` are not inspected. `smallwebwaf` changes the Core
burst; a match in `detect` mode is neither. A request a rule file refuses, one Rule Set in six ways, so that gitea's ordinary requests get through, and no
for a path `SWWAF_WAF_EXEMPT_PATHS` exempts, and one from a client in setting undoes them:
`SWWAF_ALLOW_NETS` are not inspected. `smallwebwaf` changes the Core Rule Set
in six ways, so that gitea's ordinary requests get through, and no setting
undoes them:
- `PUT`, `PATCH` and `DELETE` are allowed besides `GET`, `HEAD`, `POST` and - `PUT`, `PATCH` and `DELETE` are allowed besides `GET`, `HEAD`, `POST` and
`OPTIONS`; any other method stays refused. `OPTIONS`; any other method stays refused.
- The headers `Expect` and `Content-Encoding` are allowed; the others the - The headers `Expect` and `Content-Encoding` are allowed; the others the
Core Rule Set refuses, such as `Proxy` and `Content-Range`, stay refused. Core Rule Set refuses, such as `Proxy` and `Content-Range`, stay refused.
Once `SWWAF_WAF_BODY_LIMIT` is set, `Content-Encoding` is refused again
(rule 920450) on form data, multipart, JSON and XML, the kinds of body the
Core Rule Set reads, since a compressed body cannot be inspected; so it is
on a JSON or XML body too large to be read.
- The query parameter `redirect_uri` is not checked for a URL naming an IP - The query parameter `redirect_uri` is not checked for a URL naming an IP
address or `localhost` (rules 931100 and 934110), which Git Credential address or `localhost` (rules 931100 and 934110), which Git Credential
Manager, git-credential-oauth and tea ask to be sent back to. Manager, git-credential-oauth and tea ask to be sent back to.
@@ -240,14 +232,7 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
command names (932260), so that a file such as `.gitignore` or a branch command names (932260), so that a file such as `.gitignore` or a branch
such as `docker-build` gets through there. So does what only these rules such as `docker-build` gets through there. So does what only these rules
refuse, such as `|cat /etc/passwd`; path traversal and SQL injection are refuse, such as `|cat /etc/passwd`; path traversal and SQL injection are
still refused there. These names, and `redirect_uri` above, are matched still refused there.
without regard to case, as Coraza matches them, so `Path` or `PATH` is
treated as `path`. Once `SWWAF_WAF_BODY_LIMIT` is set, they are left out
in the same way among the fields of a form data or multipart body, which
Coraza holds with the query parameters, and gitea posts some of them in
its forms: `redirect_uri` when an OAuth sign-in is granted, and `ref` when
a workflow is run by hand. A field of a JSON body is named by its path,
such as `json.path`, and keeps these rules.
- The cookies `gitea_flash` and `redirect_to` are not read, and `Referer` is - The cookies `gitea_flash` and `redirect_to` are not read, and `Referer` is
not checked for a Unix command given without arguments (932340) or for not checked for a Unix command given without arguments (932340) or for
Java starting a process (944110); it is checked by every other rule. Java starting a process (944110); it is checked by every other rule.
@@ -710,33 +695,6 @@ effective settings are logged at start, unless `SWWAF_LOG_LEVEL` is `warn` or
Core Rule Set does not inspect, each starting with `/`, matched as Core Rule Set does not inspect, each starting with `/`, matched as
`SWWAF_RATE_LIMIT_EXEMPT_PATHS` matches its own: a request whose path holds `SWWAF_RATE_LIMIT_EXEMPT_PATHS` matches its own: a request whose path holds
`..`, a backslash or an encoded slash is inspected whatever its prefix. `..`, a backslash or an encoded slash is inspected whatever its prefix.
- `SWWAF_WAF_BODY_LIMIT` (default `off`): `off` has the Core Rule Set read no
request body. A size, such as `128K`, at most `1G`, has it read a body of form
data or multipart up to that size, the rest of a longer one passing on to the
app as it arrives, without being held, and a JSON or XML body no larger than
that size, since those cannot be read in part. A body is JSON when its type is
`application/json` or `text/json`, or an `application/` or `text/` type ending
in `+json`, and XML when its type is `application/xml` or `text/xml`, or an
`application/` or `text/` type ending in `+xml`. Any other body reaches the
app uninspected, and so does a larger JSON or XML body: the Core Rule Set
would read any other body as form data, where binary content such as a git
push trips rules written for text. A body it reads that Coraza cannot parse
(rule 900440), and a multipart body that fails Coraza's strict checks (rule
900450), add 5 to the score, as a rule rated critical does, since no rule
reads what comes after the fault. So does a multipart body the limit cuts
before the colon of a part's header line, or between the carriage return and
the line feed that end a part's header line or the empty line after its
headers, since Coraza takes the line the limit cuts for a malformed header.
The client has until `SWWAF_CLIENT_REQUEST_TIMEOUT` runs out to send the part
that is read, and a request whose body passes `SWWAF_REQUEST_MAX_BYTES` within
it is refused before anything reaches the app. Of a file in a multipart body,
Coraza counts the bytes and writes nothing. Body inspection suits apps whose
forms carry no code. In front of gitea it refuses issue and comment text, wiki
pages and files saved in the web editor that hold shell commands or code
(932125, 932235, 932250 and others), package descriptions that show code, PyPI
uploads (922130), and attachments named like `debug.log` or `config.yml`
(932180), until the rule ids the request log names are added to
`SWWAF_WAF_DISABLED_RULES`.
- `SWWAF_TRAP_PATHS` (default empty): paths the app never serves and only - `SWWAF_TRAP_PATHS` (default empty): paths the app never serves and only
scanners ask for, such as `/wp-login.php,/xmlrpc.php` in front of gitea; a scanners ask for, such as `/wp-login.php,/xmlrpc.php` in front of gitea; a
request for one bans its client for a clear sign of attack, as a `ban` rule request for one bans its client for a clear sign of attack, as a `ban` rule
@@ -1025,13 +983,13 @@ which every line has.
from when the request's headers had been read to when its line is written, and from when the request's headers had been read to when its line is written, and
`duration_checks` over the same start to when the checks were done; the health `duration_checks` over the same start to when the checks were done; the health
check runs none, and its line has no `duration_checks`. `duration_waf`, the check runs none, and its line has no `duration_checks`. `duration_waf`, the
part of the checks the Core Rule Set took, reading the part of the body it part of the checks the Core Rule Set took, is there with `waf_score`.
reads included, is there with `waf_score`. `duration_upstream_connect`, `duration_upstream_connect`, `duration_upstream_first_byte` and
`duration_upstream_first_byte` and `duration_upstream_total` are there for a `duration_upstream_total` are there for a request passed to the app, and run
request passed to the app, and run from when it was handed to the app: until from when it was handed to the app: until there was a connection to it, new or
there was a connection to it, new or kept open from an earlier request, until kept open from an earlier request, until the first byte of its answer arrived,
the first byte of its answer arrived, and until the end. The first two are and until the end. The first two are left out when that never happened, as for
left out when that never happened, as for an app that cannot be reached. an app that cannot be reached.
No body is logged, and no header but those above. `smallwebwaf`'s own messages No body is logged, and no header but those above. `smallwebwaf`'s own messages
(start, the settings, stop, errors) share the stream as JSON lines marked (start, the settings, stop, errors) share the stream as JSON lines marked
@@ -2254,17 +2212,15 @@ alerts, the metrics nor `reputation.json` hold it. Given as a file, with
which bans the client, for a DNSBL zone's verdict, for AbuseIPDB's score, for which bans the client, for a DNSBL zone's verdict, for AbuseIPDB's score, for
a rate limit, which bans the client, for a trap path, which bans the client, a rate limit, which bans the client, for a trap path, which bans the client,
for a `block` or `ban` rule, the latter banning the client, for a Core Rule for a `block` or `ban` rule, the latter banning the client, for a Core Rule
Set match in `block` mode, for a body that passes the size limit or Set match in `block` mode, and for an announced body over the size limit; in
`SWWAF_CLIENT_REQUEST_TIMEOUT` while the Core Rule Set reads it, and for an `observe` mode, only for the size limit, with what it would have refused for
announced body over the size limit; in `observe` mode, only for the last two, noted in the log line. A request under `/_smallwebwaf/` that `check` lets
with what it would have refused for noted in the log line. A request under through is answered by `answerAdmin` instead of reaching the app. Once the
`/_smallwebwaf/` that `check` lets through is answered by `answerAdmin` answer to a request passed to the app has ended, `countBytes` counts its bytes
instead of reaching the app. Once the answer to a request passed to the app for the byte limits, and once any request but the health check has ended,
has ended, `countBytes` counts its bytes for the byte limits, and once any `countRefusal` counts it for the error burst if it was refused after a rule
request but the health check has ended, `countRefusal` counts it for the error file match, a trap path or a Core Rule Set match or for its token, and
burst if it was refused after a rule file match, a trap path or a Core Rule `countAnomalies` counts it for the anomaly thresholds.
Set match or for its token, and `countAnomalies` counts it for the anomaly
thresholds.
- `internal/metrics`: the metrics, counted as the other parts tell it what - `internal/metrics`: the metrics, counted as the other parts tell it what
happened, and served in the Prometheus text format. happened, and served in the Prometheus text format.
- `internal/bans`: the ban ledger: each netblock's bans with their notes, how - `internal/bans`: the ban ledger: each netblock's bans with their notes, how
@@ -2273,8 +2229,8 @@ alerts, the metrics nor `reputation.json` hold it. Given as a file, with
- `internal/rules`: reads the rule files at start and again as they change, and - `internal/rules`: reads the rule files at start and again as they change, and
tells which of their rules a request matches. tells which of their rules a request matches.
- `internal/waf`: the Core Rule Set with the six changes, as Coraza's own - `internal/waf`: the Core Rule Set with the six changes, as Coraza's own
directives, and what it finds in a request's method, URL, headers and the part directives, and what it finds in a request's method, URL and headers: the
of its body it reads: the rules that matched and the anomaly score. rules that matched and the anomaly score.
- `internal/lookup`: looks up each client's AS number and country through GeoJS, - `internal/lookup`: looks up each client's AS number and country through GeoJS,
keeps the answers, and hands each new one to the proxy, which adds it to the keeps the answers, and hands each new one to the proxy, which adds it to the
client's history and to the notes of its bans; or in the lookup database, client's history and to the notes of its bans; or in the lookup database,
+4 -6
View File
@@ -618,12 +618,10 @@ The settings, by group:
`|cat /etc/passwd`, `wget http://…` and `nc -e /bin/sh …`, and `|cat /etc/passwd`, `wget http://…` and `nc -e /bin/sh …`, and
`file:///etc/passwd`, pass as well. Path traversal (`../`), SQL and `file:///etc/passwd`, pass as well. Path traversal (`../`), SQL and
script injection and PHP, Java and Node.js code are still refused script injection and PHP, Java and Node.js code are still refused
there, and every other parameter keeps all three rules. These names, there, and every other parameter keeps all three rules. An app that
and `redirect_uri` in the change before, are matched without regard to uses one of these parameters as a file on the server, or passes it to
case, as Coraza matches them, so `Path` or `PATH` is treated as a shell, gets no help from the three rules there (see "Risks the
`path`. An app that uses one of these parameters as a file on the design has to handle").
server, or passes it to a shell, gets no help from the three rules
there (see "Risks the design has to handle").
- The Core Rule Set reads the request without the `gitea_flash` and - The Core Rule Set reads the request without the `gitea_flash` and
`redirect_to` cookies, and does not check `Referer` for a Unix command `redirect_to` cookies, and does not check `Referer` for a Unix command
given without arguments (932340) or for Java starting a process given without arguments (932340) or for Java starting a process
+2 -31
View File
@@ -244,16 +244,14 @@ type Config struct {
// level, from 1 to 4 (SWWAF_WAF_PARANOIA_LEVEL), and // level, from 1 to 4 (SWWAF_WAF_PARANOIA_LEVEL), and
// WAFAnomalyThreshold the anomaly score at which a request is a match // WAFAnomalyThreshold the anomaly score at which a request is a match
// (SWWAF_WAF_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules // (SWWAF_WAF_ANOMALY_THRESHOLD), 0 while it is off. WAFDisabledRules
// are the ids of its rules switched off (SWWAF_WAF_DISABLED_RULES), // are the ids of its rules switched off (SWWAF_WAF_DISABLED_RULES), and
// WAFExemptPaths the path prefixes it does not inspect // WAFExemptPaths the path prefixes it does not inspect
// (SWWAF_WAF_EXEMPT_PATHS), and WAFBodyLimit the most of a request body // (SWWAF_WAF_EXEMPT_PATHS).
// it reads (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none.
WAFMode string WAFMode string
WAFParanoiaLevel int WAFParanoiaLevel int
WAFAnomalyThreshold int WAFAnomalyThreshold int
WAFDisabledRules []int WAFDisabledRules []int
WAFExemptPaths []string WAFExemptPaths []string
WAFBodyLimit int64
// TrapPaths are the paths a request for which is a clear sign of // TrapPaths are the paths a request for which is a clear sign of
// attack (SWWAF_TRAP_PATHS), each starting with / and without a ?. // attack (SWWAF_TRAP_PATHS), each starting with / and without a ?.
TrapPaths []string TrapPaths []string
@@ -398,7 +396,6 @@ var (
errNeedsDBPath = errors.New("it names the file to look clients up in") errNeedsDBPath = errors.New("it names the file to look clients up in")
errDBPathUnused = errors.New("only file reads it") errDBPathUnused = errors.New("only file reads it")
errNotOver4K = errors.New("is not a size of more than 4K, such as 32K") errNotOver4K = errors.New("is not a size of more than 4K, such as 32K")
errOver1G = errors.New("is more than 1G, the most Coraza reads")
errNotDurationAboveZero = errors.New( errNotDurationAboveZero = errors.New(
"is not a duration above zero, such as 1h or 7d") "is not a duration above zero, such as 1h or 7d")
errNotNumberAboveZero = errors.New( errNotNumberAboveZero = errors.New(
@@ -562,7 +559,6 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
WAFDisabledRules: env.ruleIDs("SWWAF_WAF_DISABLED_RULES", WAFDisabledRules: env.ruleIDs("SWWAF_WAF_DISABLED_RULES",
"920340,920420,920440,920640,930130,930140"), "920340,920420,920440,920640,930130,930140"),
WAFExemptPaths: env.pathPrefixes("SWWAF_WAF_EXEMPT_PATHS", ""), WAFExemptPaths: env.pathPrefixes("SWWAF_WAF_EXEMPT_PATHS", ""),
WAFBodyLimit: env.wafBodyLimit("SWWAF_WAF_BODY_LIMIT", off),
TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"), TrapPaths: env.trapPaths("SWWAF_TRAP_PATHS"),
ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"), ErrorBurstThreshold: env.count("SWWAF_ERROR_BURST_THRESHOLD", "30"),
LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"), LogRemoteURL: env.logRemoteURL("SWWAF_LOG_REMOTE_URL"),
@@ -768,15 +764,6 @@ func (e *environment) size(name, defaultValue string) int64 {
return size return size
} }
// wafBodyLimit reads the setting that is the most of a request body the
// Core Rule Set reads.
func (e *environment) wafBodyLimit(name, defaultValue string) int64 {
limit, err := parseWAFBodyLimit(e.value(name, defaultValue))
e.check(name, err)
return limit
}
// headerSize reads the setting that is the largest request line and // headerSize reads the setting that is the largest request line and
// headers. // headers.
func (e *environment) headerSize(name, defaultValue string) int64 { func (e *environment) headerSize(name, defaultValue string) int64 {
@@ -1445,22 +1432,6 @@ func parseHeaderSize(value string) (int64, error) {
return size, nil return size, nil
} }
// parseWAFBodyLimit reads the most of a request body the Core Rule Set
// reads: a size as parseSize reads it, or off, but at most 1G, since
// Coraza, which runs the Core Rule Set, refuses to load with more.
func parseWAFBodyLimit(value string) (int64, error) {
limit, err := parseSize(value)
if err != nil {
return 0, err
}
if limit > gibibyte {
return 0, fmt.Errorf("%q %w", value, errOver1G)
}
return limit, nil
}
// splitUnit splits a size into its number and the bytes its suffix // splitUnit splits a size into its number and the bytes its suffix
// stands for. // stands for.
func splitUnit(value string) (string, int64) { func splitUnit(value string) (string, int64) {
+3 -32
View File
@@ -96,7 +96,6 @@ const (
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD" wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD"
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES" wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS" wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
trapPaths = "SWWAF_TRAP_PATHS" trapPaths = "SWWAF_TRAP_PATHS"
errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD" errorBurstThreshold = "SWWAF_ERROR_BURST_THRESHOLD"
logRemoteURL = "SWWAF_LOG_REMOTE_URL" logRemoteURL = "SWWAF_LOG_REMOTE_URL"
@@ -581,20 +580,15 @@ 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: "128K",
}, },
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: 128 << 10,
}, },
}, },
{ {
environment{ environment{wafMode: off, wafAnomalyThreshold: off, wafDisabledRules: ""},
wafMode: off, wafAnomalyThreshold: off, wafDisabledRules: "",
wafBodyLimit: off,
},
config.Config{ config.Config{
WAFMode: config.WAFModeOff, WAFParanoiaLevel: 1, WAFAnomalyThreshold: 0, WAFMode: config.WAFModeOff, WAFParanoiaLevel: 1, WAFAnomalyThreshold: 0,
WAFDisabledRules: []int{}, WAFExemptPaths: []string{}, WAFDisabledRules: []int{}, WAFExemptPaths: []string{},
@@ -607,7 +601,6 @@ 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,
} }
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)
@@ -615,15 +608,6 @@ func TestCoreRuleSetSettings(t *testing.T) {
} }
} }
func TestWAFBodyLimitOf1G(t *testing.T) {
t.Parallel()
cfg := fromEnvironment(t, environment{wafBodyLimit: "1G"})
if cfg.WAFBodyLimit != 1<<30 {
t.Errorf("1G read as %d", cfg.WAFBodyLimit)
}
}
func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) { func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
@@ -652,28 +636,16 @@ func TestInvalidCoreRuleSetSettingStopsTheStart(t *testing.T) {
`"-942100" is not the id of a Core Rule Set rule, ` + `"-942100" is not the id of a Core Rule Set rule, ` +
`a whole number such as 942100`, `a whole number such as 942100`,
}, },
// The paranoia level, the allowed methods, the headers refused, a // The paranoia level, the allowed methods, the headers refused, and
// request with more query parameters than Coraza keeps, and a body // a request with more query parameters than Coraza keeps.
// Coraza cannot parse or that fails its strict checks.
{wafDisabledRules, "942100,900000", `"900000"` + setupRule}, {wafDisabledRules, "942100,900000", `"900000"` + setupRule},
{wafDisabledRules, "942100,900200", `"900200"` + setupRule}, {wafDisabledRules, "942100,900200", `"900200"` + setupRule},
{wafDisabledRules, "942100,900250", `"900250"` + setupRule}, {wafDisabledRules, "942100,900250", `"900250"` + setupRule},
{wafDisabledRules, "942100,900300", `"900300"` + setupRule}, {wafDisabledRules, "942100,900300", `"900300"` + setupRule},
{wafDisabledRules, "942100,900440", `"900440"` + setupRule},
{wafDisabledRules, "942100,900450", `"900450"` + setupRule},
{ {
wafExemptPaths, "api/", wafExemptPaths, "api/",
`"api/" is not a path prefix starting with /, such as /assets/`, `"api/" is not a path prefix starting with /, such as /assets/`,
}, },
{
wafBodyLimit, "128KB",
`"128KB" is not a size such as 512K, 100M or 5G, or off`,
},
{wafBodyLimit, "2G", `"2G" is more than 1G, the most Coraza reads`},
{
wafBodyLimit, "1073741825",
`"1073741825" is more than 1G, the most Coraza reads`,
},
} { } {
t.Run(tc.name+"="+tc.value, func(t *testing.T) { t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -2431,7 +2403,6 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
wafAnomalyThreshold: "5", wafAnomalyThreshold: "5",
wafDisabledRules: defaultWAFDisabledRules, wafDisabledRules: defaultWAFDisabledRules,
wafExemptPaths: "", wafExemptPaths: "",
wafBodyLimit: off,
trapPaths: "", trapPaths: "",
errorBurstThreshold: "30", errorBurstThreshold: "30",
logRemoteURL: "", logRemoteURL: "",
+1 -12
View File
@@ -21,9 +21,6 @@ type requestBody struct {
// SWWAF_REQUEST_MAX_BYTES. // SWWAF_REQUEST_MAX_BYTES.
body io.ReadCloser body io.ReadCloser
rq *request rq *request
// readByCoreRuleSet is what the Core Rule Set read of the body before
// the request went to the app, and Read gives first.
readByCoreRuleSet []byte
// waiting is true while a Read waits for the client to send more. // waiting is true while a Read waits for the client to send more.
waiting atomic.Bool waiting atomic.Bool
// received is true once the client has sent the whole body. // received is true once the client has sent the whole body.
@@ -32,16 +29,8 @@ type requestBody struct {
bytes atomic.Int64 bytes atomic.Int64
} }
// Read reads from the client's body, after what the Core Rule Set read of // Read reads from the client's body.
// it, which has been counted already.
func (b *requestBody) Read(p []byte) (int, error) { func (b *requestBody) Read(p []byte) (int, error) {
if len(b.readByCoreRuleSet) > 0 {
n := copy(p, b.readByCoreRuleSet)
b.readByCoreRuleSet = b.readByCoreRuleSet[n:]
return n, nil
}
b.waiting.Store(true) b.waiting.Store(true)
n, err := b.body.Read(p) n, err := b.body.Read(p)
b.waiting.Store(false) b.waiting.Store(false)
+2 -44
View File
@@ -1,9 +1,6 @@
package proxy package proxy
import ( import (
"errors"
"net/http"
"os"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
@@ -19,8 +16,7 @@ import (
// SWWAF_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert, // SWWAF_WAF_ANOMALY_THRESHOLD is a match: it raises the waf_block alert,
// and in block mode refuses the request, which is an offence its client's // and in block mode refuses the request, which is an offence its client's
// history counts, and so returns ActionWAFBlocked. It returns "" for a // history counts, and so returns ActionWAFBlocked. It returns "" for a
// request it does not refuse, and for one whose body meets a size or time // request it does not refuse.
// limit while the Core Rule Set reads it, which it notes nothing of.
func (rq *request) checkCoreRuleSet() string { func (rq *request) checkCoreRuleSet() string {
cfg := rq.h.config cfg := rq.h.config
if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in.URL, cfg.WAFExemptPaths) { if cfg.WAFMode == config.WAFModeOff || pathExempt(rq.in.URL, cfg.WAFExemptPaths) {
@@ -28,12 +24,7 @@ func (rq *request) checkCoreRuleSet() string {
} }
start := time.Now() start := time.Now()
result := rq.h.coreRuleSet.Inspect(rq.in, rq.client)
result := rq.inspect()
if rq.refused.Load() != nil {
return "" // the refusal for that limit, which check returns
}
rq.line.DurationWAF = new(requestlog.Milliseconds(time.Since(start))) rq.line.DurationWAF = new(requestlog.Milliseconds(time.Since(start)))
rq.line.WAFRuleIDs = result.RuleIDs rq.line.WAFRuleIDs = result.RuleIDs
rq.line.WAFScore = &result.Score rq.line.WAFScore = &result.Score
@@ -58,39 +49,6 @@ func (rq *request) checkCoreRuleSet() string {
return requestlog.ActionWAFBlocked return requestlog.ActionWAFBlocked
} }
// inspect runs the Core Rule Set on the request, which reads the part of
// its body it inspects within SWWAF_CLIENT_REQUEST_TIMEOUT, and keeps that
// part for the app. A client that runs out of time is refused with 408
// here, and a body over SWWAF_REQUEST_MAX_BYTES with 413 as it is read;
// check returns the refusal. A body that breaks off for any other reason
// is passed on as far as it came, and the request to the app fails there,
// as it would have without the Core Rule Set.
func (rq *request) inspect() waf.Result {
if rq.body == nil {
// Nothing is read of no body, so nothing can go wrong reading it.
result, _, _ := rq.h.coreRuleSet.Inspect(rq.in, rq.client, http.NoBody)
return result
}
_ = rq.rc.SetReadDeadline(rq.clientRequestDeadline())
result, read, err := rq.h.coreRuleSet.Inspect(rq.in, rq.client, rq.body)
// The timeouts that run while the request goes to the app take over.
_ = rq.rc.SetReadDeadline(time.Time{})
rq.body.readByCoreRuleSet = read
if errors.Is(err, os.ErrDeadlineExceeded) {
rq.refuse(refusal{
status: http.StatusRequestTimeout,
action: requestlog.ActionTimedOut,
limit: "SWWAF_CLIENT_REQUEST_TIMEOUT",
})
}
return result
}
// 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 SWWAF_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
-198
View File
@@ -1,14 +1,11 @@
package proxy_test package proxy_test
import ( import (
"io"
"net/http" "net/http"
"net/netip" "net/netip"
"slices" "slices"
"strconv"
"strings" "strings"
"testing" "testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
@@ -21,14 +18,10 @@ const (
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD" wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD"
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES" wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS" wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
block = "block" block = "block"
detect = "detect" detect = "detect"
) )
// formData is the type of a form's body.
const formData = "application/x-www-form-urlencoded"
// 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
// SWWAF_WAF_ANOMALY_THRESHOLD. // SWWAF_WAF_ANOMALY_THRESHOLD.
@@ -231,197 +224,6 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
} }
} }
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
t.Parallel()
const body = "id=1'%20OR%20'1'='1"
header := "Content-Type: " + formData + "\r\nContent-Length: " +
strconv.Itoa(len(body))
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
line, _ := s.requestWithBody(http.MethodPost, client, "/", header, body,
http.StatusOK, requestlog.ActionForward)
wantWAF(t, line, new(0))
s, _, _ = startWithClock(t, "", map[string]string{
wafMode: block, wafBodyLimit: sizeLimitSetting,
})
line, _ = s.requestWithBody(http.MethodPost, client, "/", header, body,
http.StatusForbidden, requestlog.ActionWAFBlocked)
wantWAF(t, line, new(5), 942100)
}
func TestBodiesReachTheAppAsSentWhileBodiesAreRead(t *testing.T) {
t.Parallel()
// The app answers with the body it was sent, once it has the whole of
// it: Go's server reads no more of a body once the answer has begun.
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
_, _ = w.Write(body)
})
addr, out := startProxy(t, app.URL, map[string]string{
wafMode: block, wafBodyLimit: sizeLimitSetting,
})
longer := "a=" + strings.Repeat("b", 64*sizeLimit)
for i, tc := range []struct {
name, contentType, body string
// announced sends the body's length in Content-Length; otherwise
// the body is sent in chunks with no length given.
announced bool
}{
{"form data within the limit", formData, "a=b", true},
{"form data longer than the limit", formData, longer, true},
{"form data longer than the limit, not announced", formData, longer, false},
{
"JSON larger than the limit", "application/json",
`{"a":"` + strings.Repeat("b", 2*sizeLimit) + `"}`, true,
},
{
"a binary body", "application/octet-stream",
strings.Repeat("\x00\xff", sizeLimit), true,
},
} {
// A reader whose length the client cannot tell is sent in chunks.
var body io.Reader = strings.NewReader(tc.body)
if !tc.announced {
body = io.MultiReader(body)
}
req := newRequest(t, http.MethodPost, addr, "/", body)
req.Header.Set("Content-Type", tc.contentType)
got := do(t, req)
if got.status != http.StatusOK || string(got.body) != tc.body {
t.Errorf("%s: the app got %d bytes, answered %d, want the %d sent, 200",
tc.name, len(got.body), got.status, len(tc.body))
}
line := out.requestLines(t, i+1)[i]
wantLine(t, line, http.StatusOK, requestlog.ActionForward)
if line.RequestBytes != int64(len(tc.body)) {
t.Errorf("%s: log line has request_bytes %d, want %d", tc.name,
line.RequestBytes, len(tc.body))
}
}
}
func TestFormBodyLongerThanTheLimitStreamsOnToTheApp(t *testing.T) {
t.Parallel()
const (
first = "a=" // and twice the limit of b's, then the rest
rest = 64 * sizeLimit
)
// past is closed once the app has received twice what the Core Rule
// Set reads, and got is the length of the whole body it received.
past := make(chan struct{})
got := make(chan int64, 1)
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
n, _ := io.CopyN(io.Discard, r.Body, 2*sizeLimit)
close(past)
m, _ := io.Copy(io.Discard, r.Body)
got <- n + m
})
addr, out := startProxy(t, app.URL, map[string]string{
wafMode: block, wafBodyLimit: sizeLimitSetting,
})
// The client sends the rest only once the app has received the first
// part: were smallwebwaf to hold the body until the end, it would
// never come.
body, sender := io.Pipe()
go func() {
_, _ = io.WriteString(sender, first+strings.Repeat("b", 2*sizeLimit))
select {
case <-past:
case <-time.After(waitLimit):
t.Error("the app got no more than the Core Rule Set reads " +
"before the whole body was sent")
_ = sender.CloseWithError(io.ErrUnexpectedEOF)
return
}
_, _ = io.WriteString(sender, strings.Repeat("b", rest))
_ = sender.Close()
}()
req := newRequest(t, http.MethodPost, addr, "/", body)
req.Header.Set("Content-Type", formData)
wantStatus(t, do(t, req), http.StatusOK)
want := int64(len(first) + 2*sizeLimit + rest)
if n := <-got; n != want {
t.Errorf("the app got %d bytes, want %d", n, want)
}
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward)
}
func TestClientTooSlowToSendWhatTheCoreRuleSetReads(t *testing.T) {
t.Parallel()
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
addr, out := startProxy(t, app.URL, map[string]string{
wafMode: block, wafBodyLimit: sizeLimitSetting,
clientRequestTimeout: shortTimeoutSetting, metricsToken: token,
})
conn := dial(t, addr)
send(t, conn, "POST /comment HTTP/1.1\r\nHost: app\r\nContent-Type: "+formData+
"\r\nContent-Length: 100\r\n\r\ncontent=the first bytes")
wantStatus(t, readResponse(t, conn), http.StatusRequestTimeout)
line := out.requestLine(t)
wantLine(t, line, http.StatusRequestTimeout, requestlog.ActionTimedOut)
wantNotSentToTheApp(t, line)
wantLimitHits(t, addr, clientRequestTimeout, 1)
}
func TestBodyOverTheSizeLimitWhileTheCoreRuleSetReadsIt(t *testing.T) {
t.Parallel()
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
addr, out := startProxy(t, app.URL, map[string]string{
wafMode: block, wafBodyLimit: "4K",
requestMaxBytes: sizeLimitSetting, metricsToken: token,
})
// Sent in chunks, its length is not announced, and is found to be over
// the limit as the Core Rule Set reads it.
body := io.MultiReader(strings.NewReader("a=" + strings.Repeat("b", 2*sizeLimit)))
req := newRequest(t, http.MethodPost, addr, "/", body)
req.Header.Set("Content-Type", formData)
wantStatus(t, do(t, req), http.StatusRequestEntityTooLarge)
line := out.requestLine(t)
wantLine(t, line, http.StatusRequestEntityTooLarge, requestlog.ActionTooLarge)
wantNotSentToTheApp(t, line)
wantLimitHits(t, addr, requestMaxBytes, 1)
}
// wantNotSentToTheApp checks that the request of line was not sent to the
// app at all.
func wantNotSentToTheApp(t *testing.T, line logLine) {
t.Helper()
_, sent := line.fields["duration_upstream_total"]
if sent {
t.Error("log line has duration_upstream_total, for a request sent to the app")
}
}
func TestResponsesAreNotInspected(t *testing.T) { func TestResponsesAreNotInspected(t *testing.T) {
t.Parallel() t.Parallel()
+4 -5
View File
@@ -232,8 +232,8 @@ func newReputation(
} }
// newCoreRuleSet returns the Core Rule Set at SWWAF_WAF_PARANOIA_LEVEL, // newCoreRuleSet returns the Core Rule Set at SWWAF_WAF_PARANOIA_LEVEL,
// without the rules SWWAF_WAF_DISABLED_RULES switches off, reading bodies // without the rules SWWAF_WAF_DISABLED_RULES switches off, or nil while
// up to SWWAF_WAF_BODY_LIMIT, or nil while SWWAF_WAF_MODE is off. // SWWAF_WAF_MODE is off.
func newCoreRuleSet(cfg *config.Config) *waf.CoreRuleSet { func newCoreRuleSet(cfg *config.Config) *waf.CoreRuleSet {
if cfg.WAFMode == config.WAFModeOff { if cfg.WAFMode == config.WAFModeOff {
return nil return nil
@@ -241,12 +241,11 @@ func newCoreRuleSet(cfg *config.Config) *waf.CoreRuleSet {
coreRuleSet, err := waf.New(waf.Params{ coreRuleSet, err := waf.New(waf.Params{
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules, ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
BodyLimit: cfg.WAFBodyLimit,
}) })
if err != nil { if err != nil {
// The Core Rule Set is built in, and the settings cannot break it: // The Core Rule Set is built in, and the settings cannot break it:
// the paranoia level is from 1 to 4, the body limit at most 1G, and // the paranoia level is from 1 to 4, and the id of no rule switches
// the id of no rule switches nothing off. // nothing off.
panic(err) panic(err)
} }
+7 -14
View File
@@ -188,23 +188,16 @@ func requestHeaders(r *http.Request, names []string) map[string]string {
} }
// check is the one place where a request can be refused once its client // check is the one place where a request can be refused once its client
// is known, before anything reaches the app, and before its body is read, // is known, before its body is read or anything reaches the app. It
// but for the part the Core Rule Set reads. It returns nil to let the // returns nil to let the request through. The checks of checkClient come
// request through. The checks of checkClient come first, answered with // first, answered with SWWAF_BAN_RESPONSE, or 403 for a block rule or the
// SWWAF_BAN_RESPONSE, or 403 for a block rule or the Core Rule Set, and // Core Rule Set, and then the size limit, so that a request the rate
// then the size limit, so that a request the rate limits count is counted // limits count is counted even when it is refused for its size. In
// even when it is refused for its size. In observe mode a request // observe mode a request checkClient refuses goes on to the size limit
// checkClient refuses goes on to the size limit like any other. A size or // like any other. ctx is the request's own context.
// time limit the Core Rule Set's reading of the body meets ends the
// request in either mode. ctx is the request's own context.
func (rq *request) check(ctx context.Context) *refusal { func (rq *request) check(ctx context.Context) *refusal {
action := rq.checkClient(ctx) action := rq.checkClient(ctx)
refused := rq.refused.Load()
if refused != nil {
return refused
}
switch { switch {
case action == "": case action == "":
case rq.h.config.Observe: case rq.h.config.Observe:
+29 -138
View File
@@ -1,18 +1,12 @@
// Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the // Package waf runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
// method, the URL with its query and the headers of a request, and on its // method, the URL with its query and the headers of a request, with the
// body while SWWAF_WAF_BODY_LIMIT is set, with the six changes smallwebwaf // six changes smallwebwaf makes to it, as "Attack detection" under
// makes to it, as "Attack detection" under "Configuration surface" in // "Configuration surface" in SPEC.md describes them. It reads no request
// SPEC.md describes them. It reads no response. // body and no response.
//
// smallwebwaf writes only to its state directory, so Coraza is built with
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
// file in a multipart body, Coraza then counts the bytes instead of
// writing them to the system's temporary directory.
package waf package waf
import ( import (
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/netip" "net/netip"
"slices" "slices"
@@ -26,16 +20,15 @@ import (
) )
// directives are the Core Rule Set as smallwebwaf runs it, with the // directives are the Core Rule Set as smallwebwaf runs it, with the
// paranoia level for %d, and bodyDirectives for %s while // paranoia level for %d. Each rule smallwebwaf adds has an id from 900000
// SWWAF_WAF_BODY_LIMIT is set. Each rule smallwebwaf adds has an id from // to 900999, the ids the Core Rule Set keeps for the rules that set it
// 900000 to 900999, the ids the Core Rule Set keeps for the rules that set // up, which SWWAF_WAF_DISABLED_RULES refuses, so that no setting switches
// it up, which SWWAF_WAF_DISABLED_RULES refuses, so that no setting // one off. Coraza joins a line ending in \ to the next, without the spaces
// switches one off. Coraza joins a line ending in \ to the next, without // at the start of the next.
// the spaces at the start of the next.
const directives = ` const directives = `
# The engine only detects. smallwebwaf compares the request's anomaly # The engine only detects. smallwebwaf compares the request's anomaly
# score with SWWAF_WAF_ANOMALY_THRESHOLD itself, in block and detect mode # score with SWWAF_WAF_ANOMALY_THRESHOLD itself, in block and detect mode
# alike. It reads no body, unless bodyDirectives switch that on. # alike. It reads no body.
SecRuleEngine DetectionOnly SecRuleEngine DetectionOnly
SecRequestBodyAccess Off SecRequestBodyAccess Off
SecResponseBodyAccess Off SecResponseBodyAccess Off
@@ -51,33 +44,19 @@ SecAction "id:900200,phase:1,pass,nolog,\
setvar:'tx.allowed_methods=GET HEAD POST OPTIONS PUT PATCH DELETE'" setvar:'tx.allowed_methods=GET HEAD POST OPTIONS PUT PATCH DELETE'"
# The second: Expect and Content-Encoding are taken off the Core Rule Set's # The second: Expect and Content-Encoding are taken off the Core Rule Set's
# list of the headers it refuses. Content-Encoding goes back on it for a # list of the headers it refuses. Content-Encoding stays refused on a body
# body the Core Rule Set reads (900260 in bodyDirectives). # the Core Rule Set reads, and it reads none.
SecAction "id:900250,phase:1,pass,nolog,\ SecAction "id:900250,phase:1,pass,nolog,\
setvar:'tx.restricted_headers_basic=/proxy/ /lock-token/ /content-range/ \ setvar:'tx.restricted_headers_basic=/proxy/ /lock-token/ /content-range/ \
/if/ /x-http-method-override/ /x-http-method/ /x-method-override/ \ /if/ /x-http-method-override/ /x-http-method/ /x-method-override/ \
/x-middleware-subrequest/'" /x-middleware-subrequest/'"
%s
# Coraza keeps the first 1000 query parameters of a request, and the first
# 1000 fields of a form data or JSON body, and drops the rest, which no
# rule then reads, so a request with more adds 5 to the score, as a rule
# the Core Rule Set rates critical does. Coraza's recommended
# configuration refuses such a request in its rules 200004 and 200005.
# This rule runs once the body is read, and before the Core Rule Set adds
# up the score in the same phase.
SecArgumentsLimit 1000
SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
# The sixth: only the rules for requests are loaded, and no response is # The sixth: only the rules for requests are loaded, and no response is
# inspected. # inspected.
Include @owasp_crs/REQUEST-*.conf Include @owasp_crs/REQUEST-*.conf
# The third: redirect_uri is not checked for a URL naming an IP address or # The third: redirect_uri is not checked for a URL naming an IP address or
# localhost. Coraza matches a parameter name here, and in the fourth, # localhost.
# without regard to case. ARGS holds the fields of a form data or multipart
# body Coraza reads as well as the query parameters, so a field of one of
# these names is left out too.
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri" SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri" SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
@@ -100,47 +79,15 @@ SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
# Inspect. # Inspect.
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer" SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
`
// bodyDirectives have the Core Rule Set read the part of a request body # Coraza keeps the first 1000 query parameters of a request and drops the
// Inspect gives it, which is at most one byte longer than the limit, up to # rest, which no rule then reads, so a request with more adds 5 to the
// the limit, %d bytes, and read JSON and XML as Coraza's recommended # score, as a rule the Core Rule Set rates critical does. Coraza's
// configuration has it in its rules 200000, 200001 and 200006, with # recommended configuration refuses such a request in its rule 200004.
// text/json, and any application or text type ending in +xml or +json, # This rule comes after the Core Rule Set's, which set the score to 0 in
// besides; form data and multipart Coraza knows by itself. %% stands for # the same phase.
// a % Coraza reads. SecArgumentsLimit 1000
const bodyDirectives = ` SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:1,pass,\
SecRequestBodyAccess On
SecRequestBodyLimit %d
SecRequestBodyLimitAction ProcessPartial
SecRule REQUEST_HEADERS:Content-Type \
"@rx ^(?:application|text)/(?:[a-z0-9.-]+[+])?xml" \
"id:900410,phase:1,pass,nolog,t:none,t:lowercase,ctl:requestBodyProcessor=XML"
SecRule REQUEST_HEADERS:Content-Type \
"@rx ^(?:application|text)/(?:[a-z0-9.-]+[+])?json" \
"id:900420,phase:1,pass,nolog,t:none,t:lowercase,ctl:requestBodyProcessor=JSON"
# The rest of the second change: Content-Encoding is refused again on a
# body of a kind the Core Rule Set reads, since a compressed body cannot be
# inspected.
SecRule REQBODY_PROCESSOR "@rx ^(?:URLENCODED|MULTIPART|JSON|XML)$" \
"id:900260,phase:1,pass,nolog,\
setvar:'tx.restricted_headers_basic=%%{tx.restricted_headers_basic} \
/content-encoding/'"
# A body Coraza fails to parse (900440), and a multipart body that fails
# its strict checks (900450), each add 5 to the score, as a rule the Core
# Rule Set rates critical does: no rule reads what comes after the fault,
# which the app may still read. Coraza's recommended configuration refuses
# them in its rules 200002 and 200003. A multipart body the limit cuts
# before the colon of a part's header line, or between the carriage return
# and the line feed that end a part's header line or the empty line after
# its headers, adds 5 too, since Coraza takes the line the limit cuts for a
# malformed header. Coraza parses any form data body.
SecRule REQBODY_ERROR "!@eq 0" "id:900440,phase:2,pass,severity:'CRITICAL',\
setvar:'tx.inbound_anomaly_score_pl1=+5'"
SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'" severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
` `
@@ -157,28 +104,18 @@ type Params struct {
// DisabledRules are the ids of the rules switched off // DisabledRules are the ids of the rules switched off
// (SWWAF_WAF_DISABLED_RULES). // (SWWAF_WAF_DISABLED_RULES).
DisabledRules []int DisabledRules []int
// BodyLimit is the most of a request body the Core Rule Set reads
// (SWWAF_WAF_BODY_LIMIT), 0 while it is off and it reads none.
BodyLimit int64
} }
// CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe // CoreRuleSet is the Core Rule Set, ready to inspect requests. It is safe
// for concurrent use. // for concurrent use.
type CoreRuleSet struct { type CoreRuleSet struct {
waf coraza.WAF waf coraza.WAF
bodyLimit int64
} }
// New returns the Core Rule Set with the six changes, at params' // New returns the Core Rule Set with the six changes, at params'
// paranoia level, without the rules it switches off, and reading request // paranoia level and without the rules it switches off.
// bodies up to params' limit.
func New(params Params) (*CoreRuleSet, error) { func New(params Params) (*CoreRuleSet, error) {
body := "" text := fmt.Sprintf(directives, params.ParanoiaLevel)
if params.BodyLimit > 0 {
body = fmt.Sprintf(bodyDirectives, params.BodyLimit)
}
text := fmt.Sprintf(directives, params.ParanoiaLevel, body)
if len(params.DisabledRules) > 0 { if len(params.DisabledRules) > 0 {
ids := make([]string, len(params.DisabledRules)) ids := make([]string, len(params.DisabledRules))
@@ -196,7 +133,7 @@ func New(params Params) (*CoreRuleSet, error) {
return nil, fmt.Errorf("load the Core Rule Set: %w", err) return nil, fmt.Errorf("load the Core Rule Set: %w", err)
} }
return &CoreRuleSet{waf: waf, bodyLimit: params.BodyLimit}, nil return &CoreRuleSet{waf: waf}, nil
} }
// Result is what the Core Rule Set found in a request. // Result is what the Core Rule Set found in a request.
@@ -210,15 +147,10 @@ type Result struct {
// Inspect runs the Core Rule Set on r, a request from client: on its // Inspect runs the Core Rule Set on r, a request from client: on its
// method, its URL with the query, and its headers, the Cookie header // method, its URL with the query, and its headers, the Cookie header
// without the cookies in cookiesNotRead, and on body, r's body as the // without the cookies in cookiesNotRead.
// caller has it, as readBody reads it. It returns what it found, what it func (c *CoreRuleSet) Inspect(r *http.Request, client netip.Addr) Result {
// read of body, which the app is still to be sent, and the error that
// ended the reading early, if one did.
func (c *CoreRuleSet) Inspect(
r *http.Request, client netip.Addr, body io.Reader,
) (Result, []byte, error) {
tx := c.waf.NewTransaction() tx := c.waf.NewTransaction()
// Closing would remove the files Coraza wrote, and it writes none. // With no body read, there is nothing whose closing can fail.
defer func() { _ = tx.Close() }() defer func() { _ = tx.Close() }()
tx.ProcessConnection(client.String(), 0, "", 0) tx.ProcessConnection(client.String(), 0, "", 0)
@@ -245,11 +177,7 @@ func (c *CoreRuleSet) Inspect(
} }
tx.ProcessRequestHeaders() tx.ProcessRequestHeaders()
// With no body read, this runs the rest of the rules, and cannot fail.
read, err := c.readBody(tx, body)
// This reads the body in memory and runs the rest of the rules, and
// cannot fail.
_, _ = tx.ProcessRequestBody() _, _ = tx.ProcessRequestBody()
var ids []int var ids []int
@@ -263,44 +191,7 @@ func (c *CoreRuleSet) Inspect(
} }
} }
return Result{RuleIDs: ids, Score: score(tx)}, read, err return Result{RuleIDs: ids, Score: score(tx)}
}
// readBody reads body, the body of the request in tx, which has run on
// the request's headers, while SWWAF_WAF_BODY_LIMIT is set and the body is
// of a kind the Core Rule Set reads: form data and multipart, of which it
// reads the first c.bodyLimit bytes, and JSON and XML, which it reads only
// when they are no longer than that, since they cannot be read in part.
// readBody reads one byte past the limit, to tell which they are, gives
// the Core Rule Set what it reads, and returns what it read and the error
// that ended the reading early, if one did.
func (c *CoreRuleSet) readBody(tx types.Transaction, body io.Reader) ([]byte, error) {
if c.bodyLimit == 0 {
return nil, nil
}
inPart := false
// How the body is read is a variable of the transaction, which only
// Coraza's interface for plugins reads.
state := tx.(plugintypes.TransactionState) //nolint:forcetypeassert // every one is
switch state.Variables().RequestBodyProcessor().Get() {
case "URLENCODED", "MULTIPART":
inPart = true
case "JSON", "XML":
default:
return nil, nil
}
read, err := io.ReadAll(io.LimitReader(body, c.bodyLimit+1))
if inPart || (err == nil && int64(len(read)) <= c.bodyLimit) {
// Coraza holds what it reads of the body in memory, up to the
// limit, so this cannot fail.
_, _, _ = tx.WriteRequestBody(read)
}
return read, err
} }
// score returns the anomaly score the Core Rule Set added up in tx, a // score returns the anomaly score the Core Rule Set added up in tx, a
+4 -382
View File
@@ -4,10 +4,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
"net/url"
"path/filepath"
"reflect" "reflect"
"strconv"
"strings" "strings"
"testing" "testing"
@@ -51,28 +48,12 @@ func get(target string, headers ...string) request {
func inspect(t *testing.T, crs *waf.CoreRuleSet, r request) waf.Result { func inspect(t *testing.T, crs *waf.CoreRuleSet, r request) waf.Result {
t.Helper() t.Helper()
result, _ := inspectBody(t, crs, r, "")
return result
}
// inspectBody is inspect for r with body, which is announced with its
// Content-Length unless it is "", and returns what crs read of body too.
func inspectBody(
t *testing.T, crs *waf.CoreRuleSet, r request, body string,
) (waf.Result, string) {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), r.method, req := httptest.NewRequestWithContext(t.Context(), r.method,
"http://git.example"+r.target, strings.NewReader(body)) "http://git.example"+r.target, http.NoBody)
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+ req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+
"Gecko/20100101 Firefox/131.0") "Gecko/20100101 Firefox/131.0")
req.Header.Set("Accept", "text/html") req.Header.Set("Accept", "text/html")
if body != "" {
req.Header.Set("Content-Length", strconv.Itoa(len(body)))
}
for _, header := range r.headers { for _, header := range r.headers {
// Go's server keeps Host and Transfer-Encoding out of the headers. // Go's server keeps Host and Transfer-Encoding out of the headers.
name, value, _ := strings.Cut(header, ": ") name, value, _ := strings.Cut(header, ": ")
@@ -86,12 +67,7 @@ func inspectBody(
} }
} }
result, read, err := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), req.Body) return crs.Inspect(req, netip.MustParseAddr("203.0.113.9"))
if err != nil {
t.Fatalf("read the body: %v", err)
}
return result, string(read)
} }
// wantResult checks what crs finds in r. // wantResult checks what crs finds in r.
@@ -163,9 +139,7 @@ func TestExpectAndContentEncodingAreAllowed(t *testing.T) {
request{http.MethodPost, push, []string{pushType, length, "Expect: 100-continue"}}, request{http.MethodPost, push, []string{pushType, length, "Expect: 100-continue"}},
waf.Result{}) waf.Result{})
wantResult(t, crs, wantResult(t, crs,
request{http.MethodPost, fetch, []string{ request{http.MethodPost, fetch, []string{fetchType, length, "Content-Encoding: gzip"}},
fetchType, length, "Content-Encoding: gzip",
}},
waf.Result{}) waf.Result{})
// Every other header on the Core Rule Set's list stays refused. // Every other header on the Core Rule Set's list stays refused.
@@ -198,7 +172,7 @@ func TestTransferEncodingIsRead(t *testing.T) {
waf.Result{}) waf.Result{})
} }
func TestMoreParametersThanCorazaKeepsIsAMatch(t *testing.T) { func TestMoreQueryParametersThanCorazaKeepsIsAMatch(t *testing.T) {
t.Parallel() t.Parallel()
const attack = "id=1'%20OR%20'1'='1" const attack = "id=1'%20OR%20'1'='1"
@@ -209,23 +183,6 @@ func TestMoreParametersThanCorazaKeepsIsAMatch(t *testing.T) {
// after it is not, but the request is a match all the same. // after it is not, but the request is a match all the same.
wantResult(t, crs, get("/?"+strings.Repeat("a=1&", 999)+attack), matched(942100)) wantResult(t, crs, get("/?"+strings.Repeat("a=1&", 999)+attack), matched(942100))
wantResult(t, crs, get("/?"+strings.Repeat("a=1&", 1000)+attack), matched(900300)) wantResult(t, crs, get("/?"+strings.Repeat("a=1&", 1000)+attack), matched(900300))
// So it is with the fields of a form data or JSON body.
crs = readingBodies(t)
for _, tc := range []struct{ header, body string }{
{formData, strings.Repeat("a=1&", 999) + attack},
{jsonBody, `{"a":[` + strings.Repeat("1,", 998) + `1],"id":"` + injection + `"}`},
} {
wantBody(t, crs, post(tc.header), tc.body, matched(942100), tc.body)
}
for _, tc := range []struct{ header, body string }{
{formData, strings.Repeat("a=1&", 1000) + attack},
{jsonBody, `{"a":[` + strings.Repeat("1,", 999) + `1],"id":"` + injection + `"}`},
} {
wantBody(t, crs, post(tc.header), tc.body, matched(900300), tc.body)
}
} }
func TestRedirectURIMayNameALocalAddress(t *testing.T) { func TestRedirectURIMayNameALocalAddress(t *testing.T) {
@@ -274,16 +231,6 @@ func TestParametersGiteaSendsNamesInSkipTheListsOfFilesPathsAndCommands(t *testi
wantResult(t, crs, get("/?path=1'%20OR%20'1'='1"), matched(942100)) wantResult(t, crs, get("/?path=1'%20OR%20'1'='1"), matched(942100))
} }
func TestParameterNamesAreMatchedWithoutRegardToCase(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
wantChange(t, crs, get("/?Path=.gitignore"), get("/?q=.gitignore"), matched(930120))
wantChange(t, crs, get("/?REDIRECT_URI=http://127.0.0.1:52341/"),
get("/?next=http://127.0.0.1:52341/"), matched(931100, 934110))
}
func TestCookiesGiteaFlashAndRedirectToAreNotRead(t *testing.T) { func TestCookiesGiteaFlashAndRedirectToAreNotRead(t *testing.T) {
t.Parallel() t.Parallel()
@@ -361,328 +308,3 @@ func TestEachDisabledRuleIsSwitchedOff(t *testing.T) {
waf.Result{RuleIDs: []int{911100, 920350}, Score: 8}) waf.Result{RuleIDs: []int{911100, 920350}, Score: 8})
wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, waf.Result{}) wantResult(t, newCoreRuleSet(t, 1, 920350, 911100), r, waf.Result{})
} }
// bodyLimit is SWWAF_WAF_BODY_LIMIT in the tests that read bodies.
const bodyLimit = 8 << 10
// The Content-Type headers of the kinds of body the Core Rule Set reads.
const (
formData = "Content-Type: application/x-www-form-urlencoded"
multipart = "Content-Type: multipart/form-data; boundary=b"
jsonBody = "Content-Type: application/json"
xmlBody = "Content-Type: application/xml"
)
// injection is an SQL injection, which rule 942100 matches.
const injection = "1' OR '1'='1"
// readingBodies returns the Core Rule Set as smallwebwaf runs it by
// default, but reading bodies up to bodyLimit.
func readingBodies(t *testing.T) *waf.CoreRuleSet {
t.Helper()
crs, err := waf.New(waf.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
})
if err != nil {
t.Fatalf("load the Core Rule Set: %v", err)
}
return crs
}
// post is a POST request for / with a body of the type contentType, a
// Content-Type header, gives, and headers besides.
func post(contentType string, headers ...string) request {
return request{http.MethodPost, "/", append([]string{contentType}, headers...)}
}
// field is a part of a multipart body: the field name, holding value.
func field(name, value string) string {
return "--b\r\nContent-Disposition: form-data; name=\"" + name + "\"\r\n\r\n" +
value + "\r\n"
}
// end ends a multipart body.
const end = "--b--\r\n"
// padded returns head and tail with as many a's between them as make n
// bytes in all.
func padded(head, tail string, n int) string {
return head + strings.Repeat("a", n-len(head)-len(tail)) + tail
}
// wantBody checks what crs finds in r with body, and that what it read of
// body is read.
func wantBody(
t *testing.T, crs *waf.CoreRuleSet, r request, body string, want waf.Result,
read string,
) {
t.Helper()
got, gotRead := inspectBody(t, crs, r, body)
if !reflect.DeepEqual(got, want) || gotRead != read {
t.Errorf("%q with a body of %d bytes, %.40q: %+v, reading %d bytes, "+
"want %+v, reading %d", r.headers, len(body), body, got, len(gotRead),
want, len(read))
}
}
func TestBodiesAreReadOnlyWhileBodyLimitIsSet(t *testing.T) {
t.Parallel()
off, on := atDefaults(t), readingBodies(t)
for _, tc := range []struct{ header, body string }{
{formData, "q=" + url.QueryEscape(injection)},
{multipart, field("q", injection) + end},
{jsonBody, `{"q":"` + injection + `"}`},
{xmlBody, "<q>" + injection + "</q>"},
} {
wantBody(t, off, post(tc.header), tc.body, waf.Result{}, "")
wantBody(t, on, post(tc.header), tc.body, matched(942100), tc.body)
}
}
func TestFormDataAndMultipartAreReadUpToTheLimit(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
pad := strings.Repeat("a", bodyLimit)
for _, tc := range []struct{ header, attackFirst, attackLast string }{
{
formData, "q=" + url.QueryEscape(injection) + "&pad=" + pad,
"pad=" + pad + "&q=" + url.QueryEscape(injection),
},
{
multipart, field("q", injection) + field("pad", pad) + end,
field("pad", pad) + field("q", injection) + end,
},
} {
wantBody(t, crs, post(tc.header), tc.attackFirst, matched(942100),
tc.attackFirst[:bodyLimit+1])
wantBody(t, crs, post(tc.header), tc.attackLast, waf.Result{},
tc.attackLast[:bodyLimit+1])
}
// To the byte: a system file's path is found when it ends at the limit,
// and not when its last letter is past it, which is still read.
atLimit := padded("pad=", "&q=/etc/passwd", bodyLimit)
wantBody(t, crs, post(formData), atLimit, matched(930120, 932160), atLimit)
pastLimit := padded("pad=", "&q=/etc/passwd", bodyLimit+1)
wantBody(t, crs, post(formData), pastLimit, waf.Result{}, pastLimit)
}
func TestJSONAndXMLAreReadOnlyWhenNoLargerThanTheLimit(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
for _, tc := range []struct{ header, head, tail string }{
{jsonBody, `{"q":"` + injection + `","pad":"`, `"}`},
{xmlBody, "<r><q>" + injection + "</q><pad>", "</pad></r>"},
} {
fits := padded(tc.head, tc.tail, bodyLimit)
wantBody(t, crs, post(tc.header), fits, matched(942100), fits)
larger := padded(tc.head, tc.tail, bodyLimit+1)
wantBody(t, crs, post(tc.header), larger, waf.Result{}, larger)
}
}
func TestOtherBodiesAreNotRead(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
// Read as form data, which the Core Rule Set does with a body of a type
// it does not know, this would be an SQL injection.
body := "q=" + url.QueryEscape(injection)
for _, header := range []string{
"Content-Type: application/octet-stream",
"Content-Type: text/plain",
"Content-Type: application/x-git-receive-pack-request",
} {
wantBody(t, crs, post(header), body, waf.Result{}, "")
}
}
func TestContentEncodingIsRefusedOnTheKindsOfBodyTheCoreRuleSetReads(t *testing.T) {
t.Parallel()
const gzip = "Content-Encoding: gzip"
crs := readingBodies(t)
for _, tc := range []struct{ header, body string }{
{formData, "a=1"},
{multipart, field("a", "1") + end},
{jsonBody, `{"a":1}`},
{xmlBody, "<a>1</a>"},
} {
wantBody(t, crs, post(tc.header, gzip), tc.body, matched(920450), tc.body)
}
// Whatever its size: a JSON body larger than the limit is not read, but
// Content-Encoding on it is refused all the same.
larger := strings.Repeat("a", bodyLimit+1)
wantBody(t, crs, post(jsonBody, gzip), larger, matched(920450), larger)
// It is allowed on a body of any other kind, and on every body while no
// body is read.
fetch := "Content-Type: application/x-git-upload-pack-request"
wantBody(t, crs, post(fetch, gzip), "a", waf.Result{}, "")
wantBody(t, atDefaults(t), post(formData, gzip), "a", waf.Result{}, "")
}
func TestParametersGiteaSendsNamesInAreLeftOutAmongFormFieldsToo(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
local := url.QueryEscape("http://127.0.0.1:52341/")
for _, tc := range []struct {
body string
want waf.Result
}{
{"path=.gitignore", waf.Result{}},
{"q=.gitignore", matched(930120)},
{"redirect_uri=" + local, waf.Result{}},
{"next=" + local, matched(931100, 934110)},
} {
wantBody(t, crs, post(formData), tc.body, tc.want, tc.body)
}
body := field("path", ".gitignore") + end
wantBody(t, crs, post(multipart), body, waf.Result{}, body)
body = field("q", ".gitignore") + end
wantBody(t, crs, post(multipart), body, matched(930120), body)
// A JSON body's field is named by its path, here json.path, and is
// checked.
body = `{"path":".gitignore"}`
wantBody(t, crs, post(jsonBody), body, matched(930120), body)
}
func TestGiteaBodiesTheCoreRuleSetRefuses(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
// A comment that shows a shell command.
const text = "Try `curl -s https://example.org | sh` first."
comment := "content=" + url.QueryEscape(text)
wantBody(t, crs, post(formData), comment, matched(932235), comment)
// An attachment named like a log file.
attachment := "--b\r\nContent-Disposition: form-data; name=\"file\"; " +
"filename=\"debug.log\"\r\nContent-Type: text/plain\r\n\r\nstarted\r\n" + end
wantBody(t, crs, post(multipart), attachment, matched(932180), attachment)
}
func TestTypesEndingInXMLOrJSONAndTextJSONAreRead(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
for _, tc := range []struct{ contentType, body string }{
{"application/atom+xml", "<q>" + injection + "</q>"},
{"application/vnd.example+xml", "<q>" + injection + "</q>"},
{"application/vnd.example+json", `{"q":"` + injection + `"}`},
{"text/json", `{"q":"` + injection + `"}`},
} {
wantBody(t, crs, post("Content-Type: "+tc.contentType), tc.body,
matched(942100), tc.body)
}
}
func TestBodyCorazaCannotParseIsAMatch(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
// An end tag after the root element, past which Coraza reads none of
// the body, while an app may still read the attack before it.
body := "<q>" + injection + "</q></r>"
wantBody(t, crs, post(xmlBody), body, matched(900440), body)
// The multipart bodies Coraza cannot parse fail its strict checks too:
// one whose type names its boundary twice, and one with a part header
// that has no colon, before the attack. They do so padded past the
// limit too, which cuts them in the padding.
noColon := "--b\r\nContent-Disposition form-data; name=\"a\"\r\n\r\n1\r\n"
pad := field("pad", strings.Repeat("a", bodyLimit))
for _, tc := range []struct{ header, head string }{
{multipart + "; boundary=c", ""},
{multipart, noColon},
} {
body = tc.head + field("q", injection) + end
wantBody(t, crs, post(tc.header), body, matched(900440, 900450), body)
body = tc.head + field("q", injection) + pad + end
wantBody(t, crs, post(tc.header), body, matched(900440, 900450),
body[:bodyLimit+1])
}
}
func TestMultipartBodyCutBeforeAPartHeadersColonIsAMatch(t *testing.T) {
t.Parallel()
// The limit falls in the middle of the name of the second part's
// header, which Coraza, reading up to the limit, cannot tell from a
// header without a colon.
cut := "--b\r\nContent-Di"
first := field("pad", strings.Repeat("a", bodyLimit-len(field("pad", ""))-len(cut)))
body := first + cut + "sposition: form-data; name=\"q\"\r\n\r\n1\r\n" + end
wantBody(t, readingBodies(t), post(multipart), body, matched(900440, 900450),
body[:bodyLimit+1])
}
func TestMultipartBodyCutBeforeALineFeedInAPartsHeadersIsAMatch(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
headerLine := "--b\r\nContent-Disposition: form-data; name=\"q\"\r"
// The limit falls between the carriage return and the line feed that
// end the second part's header line, and then between those that end
// the empty line after it. Coraza, reading up to the limit, takes the
// line ending in a lone carriage return for a malformed header.
for _, cut := range []int{len(headerLine), len(headerLine + "\n\r")} {
first := field("pad", strings.Repeat("a", bodyLimit-len(field("pad", ""))-cut))
body := first + field("q", "1") + end
wantBody(t, crs, post(multipart), body, matched(900440, 900450),
body[:bodyLimit+1])
}
}
// TestCorazaWritesNoFile is not parallel, since it sets TMPDIR, the
// system's temporary directory, for the whole test process.
func TestCorazaWritesNoFile(t *testing.T) {
// The system's temporary directory is one that does not exist, so that
// Coraza could write nothing there: built without no_fs_access, it
// refuses to load, and could not write a file of a multipart body.
t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
body := "--b\r\nContent-Disposition: form-data; name=\"file\"; " +
"filename=\"notes.txt\"\r\nContent-Type: text/plain\r\n\r\n" +
strings.Repeat("a", 1000) + "\r\n" + field("q", injection) + end
wantBody(t, readingBodies(t), post(multipart), body, matched(942100), body)
}
func TestBodyLimitOf1GLoads(t *testing.T) {
t.Parallel()
_, err := waf.New(waf.Params{ParanoiaLevel: 1, BodyLimit: 1 << 30})
if err != nil {
t.Errorf("load the Core Rule Set reading bodies up to 1G: %v", err)
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# script/build: build bin/smallwebwaf on the host, with Go installed, for # script/build: build bin/smallwebwaf on the host, with Go installed, for
# working on the code by hand. The version it reports comes from git, as # working on the code by hand. The version it reports comes from git, as
# in script/docker, and the no_fs_access tag is the Dockerfile's. # in script/docker.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -11,7 +11,7 @@ main() {
cd "$ROOT" cd "$ROOT"
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
go build -tags no_fs_access -trimpath -ldflags "-X main.Version=$version" \ go build -trimpath -ldflags "-X main.Version=$version" \
-o bin/smallwebwaf ./cmd/smallwebwaf -o bin/smallwebwaf ./cmd/smallwebwaf
} }