Core Rule Set reads request bodies up to SWWAF_WAF_BODY_LIMIT (closes #116)
check / check (push) Waiting to run
check / check (push) Waiting to run
SWWAF_WAF_BODY_LIMIT (default off, at most 1G) has the Core Rule Set read form data and multipart up to the limit, the rest streaming on, and JSON and XML (with +json, text/json and +xml) no larger than it. The part read is held for the app. A size or time limit met while reading ends the request. Content-Encoding is refused again on these kinds. A body Coraza cannot parse, or a multipart body failing its strict checks, adds 5, but not a multipart body reaching the limit. Coraza is built with no_fs_access, so writes no file. Rule 900300 moves to phase 2. Judgement call: Content-Encoding is refused on a JSON or XML body too large to read, as SPEC.md allows. Model: opus-5-5
This commit is contained in:
+9
-5
@@ -36,11 +36,12 @@ 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.
|
# after this step, and writing it into the image takes seconds. The tests
|
||||||
|
# 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 -timeout 90s -race -cover ./... || \
|
go test -tags no_fs_access -timeout 90s -race -cover ./... || \
|
||||||
{ echo "--- Rerunning with -v for details ---"; \
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
go test -timeout 90s -race -v ./...; exit 1; }
|
go test -tags no_fs_access -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
|
||||||
@@ -84,7 +85,10 @@ 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.
|
# 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
|
||||||
|
# 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 \
|
||||||
@@ -93,7 +97,7 @@ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
|||||||
exit 1 ;; \
|
exit 1 ;; \
|
||||||
esac; \
|
esac; \
|
||||||
fi; \
|
fi; \
|
||||||
CGO_ENABLED=0 go build -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/smallwebwaf ./cmd/smallwebwaf
|
-o /usr/local/bin/smallwebwaf ./cmd/smallwebwaf
|
||||||
|
|
||||||
|
|||||||
@@ -64,11 +64,10 @@ 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 no request body yet:
|
app's own image is built on. The Core Rule Set reads a request's body too once
|
||||||
`SWWAF_WAF_BODY_LIMIT`, which switches that on, comes with
|
`SWWAF_WAF_BODY_LIMIT` is set. The rest of the design comes next, in the order
|
||||||
https://git.eeqj.de/sneak/smallwebwaf/issues/116. The rest of the design comes
|
of the build order in [`SPEC.md`](SPEC.md). The survey of existing tools that
|
||||||
after that, in the order of the build order in [`SPEC.md`](SPEC.md). The survey
|
led to the design is in [`EVALUATION.md`](EVALUATION.md).
|
||||||
of existing tools that led to the design is in [`EVALUATION.md`](EVALUATION.md).
|
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
@@ -98,7 +97,9 @@ 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. A WebSocket, or any other
|
directions and are never held whole in memory, but for the part of a request
|
||||||
|
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.
|
||||||
@@ -203,26 +204,33 @@ 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, but no request body and no response. Each of
|
with the query, and its headers, and, once `SWWAF_WAF_BODY_LIMIT` is set, its
|
||||||
its rules that matches adds to the request's anomaly score, up to the paranoia
|
body when it is form data, multipart, JSON or XML, as that setting below
|
||||||
level `SWWAF_WAF_PARANOIA_LEVEL` sets. A request with more than 1000 query
|
describes; no response is inspected. Each of its rules that matches adds to
|
||||||
parameters adds 5 (rule 900300), as a rule rated critical does, since Coraza
|
the request's anomaly score, up to the paranoia level
|
||||||
reads only the first 1000. A score at or over `SWWAF_WAF_ANOMALY_THRESHOLD`, 5
|
`SWWAF_WAF_PARANOIA_LEVEL` sets. A request with more than 1000 query
|
||||||
by default, is a match: in `block` mode, the default, the request is refused
|
parameters, or more than 1000 fields in a form data or JSON body it reads,
|
||||||
with `403`, and in `detect` mode it goes on to the app. Either way its log
|
adds 5 (rule 900300), as a rule rated critical does, since Coraza reads only
|
||||||
line names the rules and the score (see `waf_rule_ids` and `waf_score` in
|
the first 1000. A score at or over `SWWAF_WAF_ANOMALY_THRESHOLD`, 5 by
|
||||||
"Request log" below), and it raises a `waf_block` alert. A refusal bans no one
|
default, is a match: in `block` mode, the default, the request is refused with
|
||||||
by itself, since the Core Rule Set takes some ordinary requests for attacks,
|
`403`, and in `detect` mode it goes on to the app. Either way its log line
|
||||||
but it is an offence the client's history counts, and it counts toward the
|
names the rules and the score (see `waf_rule_ids` and `waf_score` in "Request
|
||||||
error burst; a match in `detect` mode is neither. A request a rule file
|
log" below), and it raises a `waf_block` alert. A refusal bans no one by
|
||||||
refuses, one for a path `SWWAF_WAF_EXEMPT_PATHS` exempts, and one from a
|
itself, since the Core Rule Set takes some ordinary requests for attacks, but
|
||||||
client in `SWWAF_ALLOW_NETS` are not inspected. `smallwebwaf` changes the Core
|
it is an offence the client's history counts, and it counts toward the error
|
||||||
Rule Set in six ways, so that gitea's ordinary requests get through, and no
|
burst; a match in `detect` mode is neither. A request a rule file refuses, one
|
||||||
setting undoes them:
|
for a path `SWWAF_WAF_EXEMPT_PATHS` exempts, and one from a client in
|
||||||
|
`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.
|
||||||
@@ -234,7 +242,12 @@ in `bin/state` unless `SWWAF_STATE_DIR` is set, and the default rule file of
|
|||||||
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. These names, and `redirect_uri` above, are matched
|
||||||
without regard to case, as Coraza matches them, so `Path` or `PATH` is
|
without regard to case, as Coraza matches them, so `Path` or `PATH` is
|
||||||
treated as `path`.
|
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.
|
||||||
@@ -697,6 +710,29 @@ 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 whose type ends in
|
||||||
|
`+json`, or is `text/json`, is JSON, and one whose type ends in `+xml` is 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; a multipart body that
|
||||||
|
reaches the limit does not, since the limit can cut it inside a part's 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
|
||||||
@@ -985,13 +1021,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, is there with `waf_score`.
|
part of the checks the Core Rule Set took, reading the part of the body it
|
||||||
`duration_upstream_connect`, `duration_upstream_first_byte` and
|
reads included, is there with `waf_score`. `duration_upstream_connect`,
|
||||||
`duration_upstream_total` are there for a request passed to the app, and run
|
`duration_upstream_first_byte` and `duration_upstream_total` are there for a
|
||||||
from when it was handed to the app: until there was a connection to it, new or
|
request passed to the app, and run from when it was handed to the app: until
|
||||||
kept open from an earlier request, until the first byte of its answer arrived,
|
there was a connection to it, new or kept open from an earlier request, until
|
||||||
and until the end. The first two are left out when that never happened, as for
|
the first byte of its answer arrived, and until the end. The first two are
|
||||||
an app that cannot be reached.
|
left out when that never happened, as for 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
|
||||||
@@ -2214,15 +2250,17 @@ 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, and for an announced body over the size limit; in
|
Set match in `block` mode, for a body that passes the size limit or
|
||||||
`observe` mode, only for the size limit, with what it would have refused for
|
`SWWAF_CLIENT_REQUEST_TIMEOUT` while the Core Rule Set reads it, and for an
|
||||||
noted in the log line. A request under `/_smallwebwaf/` that `check` lets
|
announced body over the size limit; in `observe` mode, only for the last two,
|
||||||
through is answered by `answerAdmin` instead of reaching the app. Once the
|
with what it would have refused for noted in the log line. A request under
|
||||||
answer to a request passed to the app has ended, `countBytes` counts its bytes
|
`/_smallwebwaf/` that `check` lets through is answered by `answerAdmin`
|
||||||
for the byte limits, and once any request but the health check has ended,
|
instead of reaching the app. Once the answer to a request passed to the app
|
||||||
`countRefusal` counts it for the error burst if it was refused after a rule
|
has ended, `countBytes` counts its bytes for the byte limits, and once any
|
||||||
file match, a trap path or a Core Rule Set match or for its token, and
|
request but the health check has ended, `countRefusal` counts it for the error
|
||||||
`countAnomalies` counts it for the anomaly thresholds.
|
burst if it was refused after a rule file match, a trap path or a Core Rule
|
||||||
|
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
|
||||||
@@ -2231,8 +2269,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 and headers: the
|
directives, and what it finds in a request's method, URL, headers and the part
|
||||||
rules that matched and the anomaly score.
|
of its body it reads: the 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,
|
||||||
|
|||||||
@@ -244,14 +244,16 @@ 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), and
|
// are the ids of its rules switched off (SWWAF_WAF_DISABLED_RULES),
|
||||||
// WAFExemptPaths the path prefixes it does not inspect
|
// WAFExemptPaths the path prefixes it does not inspect
|
||||||
// (SWWAF_WAF_EXEMPT_PATHS).
|
// (SWWAF_WAF_EXEMPT_PATHS), and WAFBodyLimit the most of a request body
|
||||||
|
// 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
|
||||||
@@ -396,6 +398,7 @@ 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(
|
||||||
@@ -559,6 +562,7 @@ 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"),
|
||||||
@@ -764,6 +768,15 @@ 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 {
|
||||||
@@ -1432,6 +1445,22 @@ 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) {
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ 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"
|
||||||
@@ -580,15 +581,20 @@ 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{wafMode: off, wafAnomalyThreshold: off, wafDisabledRules: ""},
|
environment{
|
||||||
|
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{},
|
||||||
@@ -601,6 +607,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,
|
||||||
}
|
}
|
||||||
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)
|
||||||
@@ -608,6 +615,15 @@ 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()
|
||||||
|
|
||||||
@@ -636,16 +652,28 @@ 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, and
|
// The paranoia level, the allowed methods, the headers refused, a
|
||||||
// a request with more query parameters than Coraza keeps.
|
// request with more query parameters than Coraza keeps, and a body
|
||||||
|
// 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()
|
||||||
@@ -2403,6 +2431,7 @@ 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: "",
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ 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.
|
||||||
@@ -29,8 +32,16 @@ type requestBody struct {
|
|||||||
bytes atomic.Int64
|
bytes atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read reads from the client's body.
|
// Read reads from the client's body, after what the Core Rule Set read of
|
||||||
|
// 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)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
@@ -16,7 +19,8 @@ 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.
|
// request it does not refuse, and for one whose body meets a size or time
|
||||||
|
// 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) {
|
||||||
@@ -24,7 +28,12 @@ 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
|
||||||
@@ -49,6 +58,39 @@ 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
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
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"
|
||||||
@@ -18,10 +21,14 @@ 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.
|
||||||
@@ -224,6 +231,197 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -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, or nil while
|
// without the rules SWWAF_WAF_DISABLED_RULES switches off, reading bodies
|
||||||
// SWWAF_WAF_MODE is off.
|
// up to SWWAF_WAF_BODY_LIMIT, or nil while 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,11 +241,12 @@ 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, and the id of no rule switches
|
// the paranoia level is from 1 to 4, the body limit at most 1G, and
|
||||||
// nothing off.
|
// the id of no rule switches nothing off.
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,16 +188,23 @@ 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 its body is read or anything reaches the app. It
|
// is known, before anything reaches the app, and before its body is read,
|
||||||
// returns nil to let the request through. The checks of checkClient come
|
// but for the part the Core Rule Set reads. It returns nil to let the
|
||||||
// first, answered with SWWAF_BAN_RESPONSE, or 403 for a block rule or the
|
// request through. The checks of checkClient come first, answered with
|
||||||
// Core Rule Set, and then the size limit, so that a request the rate
|
// SWWAF_BAN_RESPONSE, or 403 for a block rule or the Core Rule Set, and
|
||||||
// limits count is counted even when it is refused for its size. In
|
// then the size limit, so that a request the rate limits count is counted
|
||||||
// observe mode a request checkClient refuses goes on to the size limit
|
// even when it is refused for its size. In observe mode a request
|
||||||
// like any other. ctx is the request's own context.
|
// checkClient refuses goes on to the size limit like any other. A size or
|
||||||
|
// 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:
|
||||||
|
|||||||
+138
-29
@@ -1,12 +1,18 @@
|
|||||||
// 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, with the
|
// method, the URL with its query and the headers of a request, and on its
|
||||||
// six changes smallwebwaf makes to it, as "Attack detection" under
|
// body while SWWAF_WAF_BODY_LIMIT is set, with the six changes smallwebwaf
|
||||||
// "Configuration surface" in SPEC.md describes them. It reads no request
|
// makes to it, as "Attack detection" under "Configuration surface" in
|
||||||
// body and no response.
|
// SPEC.md describes them. It reads 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"
|
||||||
@@ -20,15 +26,16 @@ 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. Each rule smallwebwaf adds has an id from 900000
|
// paranoia level for %d, and bodyDirectives for %s while
|
||||||
// to 900999, the ids the Core Rule Set keeps for the rules that set it
|
// SWWAF_WAF_BODY_LIMIT is set. Each rule smallwebwaf adds has an id from
|
||||||
// up, which SWWAF_WAF_DISABLED_RULES refuses, so that no setting switches
|
// 900000 to 900999, the ids the Core Rule Set keeps for the rules that set
|
||||||
// one off. Coraza joins a line ending in \ to the next, without the spaces
|
// it up, which SWWAF_WAF_DISABLED_RULES refuses, so that no setting
|
||||||
// at the start of the next.
|
// switches one off. Coraza joins a line ending in \ to the next, without
|
||||||
|
// 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.
|
# alike. It reads no body, unless bodyDirectives switch that on.
|
||||||
SecRuleEngine DetectionOnly
|
SecRuleEngine DetectionOnly
|
||||||
SecRequestBodyAccess Off
|
SecRequestBodyAccess Off
|
||||||
SecResponseBodyAccess Off
|
SecResponseBodyAccess Off
|
||||||
@@ -44,12 +51,23 @@ 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 stays refused on a body
|
# list of the headers it refuses. Content-Encoding goes back on it for a
|
||||||
# the Core Rule Set reads, and it reads none.
|
# body the Core Rule Set reads (900260 in bodyDirectives).
|
||||||
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.
|
||||||
@@ -57,7 +75,9 @@ 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. Coraza matches a parameter name here, and in the fourth,
|
||||||
# without regard to case.
|
# 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"
|
||||||
|
|
||||||
@@ -80,15 +100,48 @@ 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"
|
||||||
|
`
|
||||||
|
|
||||||
# Coraza keeps the first 1000 query parameters of a request and drops the
|
// bodyDirectives have the Core Rule Set read the part of a request body
|
||||||
# rest, which no rule then reads, so a request with more adds 5 to the
|
// Inspect gives it, which is at most one byte longer than the limit, up to
|
||||||
# score, as a rule the Core Rule Set rates critical does. Coraza's
|
// the limit, %d bytes, and read JSON and XML as Coraza's recommended
|
||||||
# recommended configuration refuses such a request in its rule 200004.
|
// configuration has it in its rules 200000, 200001 and 200006, with any
|
||||||
# This rule comes after the Core Rule Set's, which set the score to 0 in
|
// type ending in +xml, and text/json, besides; form data and multipart
|
||||||
# the same phase.
|
// Coraza knows by itself. %% stands for 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 that reaches the
|
||||||
|
# limit is let off both (900430), since the limit can cut it inside a
|
||||||
|
# part's header, which Coraza takes for such a fault. Coraza parses any
|
||||||
|
# form data body.
|
||||||
|
SecRule INBOUND_DATA_ERROR "@eq 1" "id:900430,phase:2,pass,nolog,chain"
|
||||||
|
SecRule REQBODY_PROCESSOR "@streq MULTIPART" \
|
||||||
|
"ctl:ruleRemoveById=900440,ctl:ruleRemoveById=900450"
|
||||||
|
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'"
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -105,18 +158,28 @@ 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 and without the rules it switches off.
|
// paranoia level, without the rules it switches off, and reading request
|
||||||
|
// bodies up to params' limit.
|
||||||
func New(params Params) (*CoreRuleSet, error) {
|
func New(params Params) (*CoreRuleSet, error) {
|
||||||
text := fmt.Sprintf(directives, params.ParanoiaLevel)
|
body := ""
|
||||||
|
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))
|
||||||
@@ -134,7 +197,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}, nil
|
return &CoreRuleSet{waf: waf, bodyLimit: params.BodyLimit}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result is what the Core Rule Set found in a request.
|
// Result is what the Core Rule Set found in a request.
|
||||||
@@ -148,10 +211,15 @@ 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.
|
// without the cookies in cookiesNotRead, and on body, r's body as the
|
||||||
func (c *CoreRuleSet) Inspect(r *http.Request, client netip.Addr) Result {
|
// caller has it, as readBody reads it. It returns what it found, what it
|
||||||
|
// 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()
|
||||||
// With no body read, there is nothing whose closing can fail.
|
// Closing would remove the files Coraza wrote, and it writes none.
|
||||||
defer func() { _ = tx.Close() }()
|
defer func() { _ = tx.Close() }()
|
||||||
|
|
||||||
tx.ProcessConnection(client.String(), 0, "", 0)
|
tx.ProcessConnection(client.String(), 0, "", 0)
|
||||||
@@ -178,7 +246,11 @@ func (c *CoreRuleSet) Inspect(r *http.Request, client netip.Addr) Result {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -192,7 +264,44 @@ func (c *CoreRuleSet) Inspect(r *http.Request, client netip.Addr) Result {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result{RuleIDs: ids, Score: score(tx)}
|
return Result{RuleIDs: ids, Score: score(tx)}, read, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|||||||
+341
-3
@@ -4,7 +4,10 @@ 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"
|
||||||
|
|
||||||
@@ -48,12 +51,28 @@ 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, http.NoBody)
|
"http://git.example"+r.target, strings.NewReader(body))
|
||||||
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, ": ")
|
||||||
@@ -67,7 +86,12 @@ func inspect(t *testing.T, crs *waf.CoreRuleSet, r request) waf.Result {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return crs.Inspect(req, netip.MustParseAddr("203.0.113.9"))
|
result, read, err := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), req.Body)
|
||||||
|
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.
|
||||||
@@ -174,7 +198,7 @@ func TestTransferEncodingIsRead(t *testing.T) {
|
|||||||
waf.Result{})
|
waf.Result{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMoreQueryParametersThanCorazaKeepsIsAMatch(t *testing.T) {
|
func TestMoreParametersThanCorazaKeepsIsAMatch(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const attack = "id=1'%20OR%20'1'='1"
|
const attack = "id=1'%20OR%20'1'='1"
|
||||||
@@ -185,6 +209,23 @@ func TestMoreQueryParametersThanCorazaKeepsIsAMatch(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) {
|
||||||
@@ -320,3 +361,300 @@ 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.
|
||||||
|
body = field("q", injection) + end
|
||||||
|
wantBody(t, crs, post(multipart+"; boundary=c"), body, matched(900440, 900450),
|
||||||
|
body)
|
||||||
|
|
||||||
|
body = "--b\r\nContent-Disposition form-data; name=\"a\"\r\n\r\n1\r\n" +
|
||||||
|
field("q", injection) + end
|
||||||
|
wantBody(t, crs, post(multipart), body, matched(900440, 900450), body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipartBodyCutInsideAPartHeaderIsNotAMatch(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, finds 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, waf.Result{},
|
||||||
|
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
@@ -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.
|
# in script/docker, and the no_fs_access tag is the Dockerfile's.
|
||||||
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 -trimpath -ldflags "-X main.Version=$version" \
|
go build -tags no_fs_access -trimpath -ldflags "-X main.Version=$version" \
|
||||||
-o bin/smallwebwaf ./cmd/smallwebwaf
|
-o bin/smallwebwaf ./cmd/smallwebwaf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user