Compare commits
1
Commits
next
..
df8c0ebb29
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df8c0ebb29 |
+5
-9
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user