Files
smallwebwaf/internal/coreruleset/recorded_test.go
T
sneak 4470596886
check / check (push) Waiting to run
SWW_APPS: an app's Core Rule Set fixes apply only to the hostnames that serve it (closes #220)
Each app's fixes are one .conf file and one entry in apps. They apply
only while Inspect has set the app's transaction variable, such as
tx.app_gitea. SWW_APPS names each hostname's apps. A hostname gets only
its apps' fixes; while SWW_APPS is unset, every hostname gets every
app's fixes. Listing forgejo, a fork of gitea, brings gitea's fixes too.

A hostname in SWW_APPS that is not a DNS name, such as *.example.com,
stops the start. Unset, SWW_APPS is logged at start as null; set
empty, as "".

Model: opus-5-5
2026-11-11 12:18:30 +00:00

218 lines
6.0 KiB
Go

//go:build recorded
// The tests that check the apps' recorded requests in testdata. The recorded
// tag keeps them out of the test phase's pass with the race detector, and
// its second pass, without the race detector, runs them.
package coreruleset_test
import (
"encoding/json"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
)
// giteaRequests returns the recorded ordinary gitea requests in testdata.
// Each has the Referer of a page that names the same, as a page opened
// from it has.
func giteaRequests(t *testing.T) []request {
t.Helper()
text, err := os.ReadFile(filepath.Join("testdata", "gitea-requests.txt"))
if err != nil {
t.Fatal(err)
}
var requests []request
for line := range strings.Lines(string(text)) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
target, header, _ := strings.Cut(line, " ")
headers := []string{"Referer: http://git.example" + target}
if header != "" {
headers = append(headers, header)
}
requests = append(requests, get(target, headers...))
}
return requests
}
func TestOrdinaryGiteaRequestsPass(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
crs := servingApps(t, map[string][]string{giteaHost: {gitea}})
for _, r := range giteaRequests(t) {
if got := inspect(t, crs, withHost(r, giteaHost)); got.Score >= threshold {
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
}
}
}
// recordedRequest is a line of a file of recorded requests in testdata.
type recordedRequest struct {
Method string `json:"method"`
Target string `json:"target"`
Headers []string `json:"headers"`
Body string `json:"body"`
}
// recordedRequests returns the recorded ordinary requests in the file name
// in testdata, each with its body.
func recordedRequests(t *testing.T, name string) []recordedRequest {
t.Helper()
path := filepath.Join("testdata", name)
text, err := os.ReadFile(path) //nolint:gosec // a file in testdata
if err != nil {
t.Fatal(err)
}
var requests []recordedRequest
for line := range strings.Lines(string(text)) {
if strings.HasPrefix(line, "#") {
continue
}
var r recordedRequest
err := json.Unmarshal([]byte(line), &r)
if err != nil {
t.Fatalf("%q: %v", line, err)
}
requests = append(requests, r)
}
return requests
}
// wantRecordedRequestsPass checks that none of the recorded ordinary
// requests in the file name in testdata is refused on the hostname where
// they were recorded, otherHost for WordPress and example.com for the other
// apps, while SWW_APPS lists only app for it. They are checked at the
// defaults and while bodies are read. With SWW_APPS unset, every app's fixes
// apply. These only switch checks off, so the requests pass then too. A
// request with neither a body nor a Content-Type header is checked only at
// the defaults: with neither, the set that reads bodies has nothing more to
// check. Each request runs as its own parallel subtest.
func wantRecordedRequestsPass(t *testing.T, name, app string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
apps := map[string][]string{"example.com": {app}, otherHost: {app}}
defaults := servingApps(t, apps)
bodies := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
Apps: apps,
})
for _, recorded := range recordedRequests(t, name) {
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
t.Parallel()
r := request{recorded.Method, recorded.Target, recorded.Headers}
got, _ := inspectBody(t, defaults, r, recorded.Body)
if got.Score >= threshold {
t.Errorf("%s %s refused: %+v", r.method, r.target, got)
}
typed := slices.ContainsFunc(recorded.Headers, func(header string) bool {
name, _, _ := strings.Cut(header, ":")
return strings.EqualFold(name, "Content-Type")
})
if recorded.Body == "" && !typed {
return
}
got, _ = inspectBody(t, bodies, r, recorded.Body)
if got.Score >= threshold {
t.Errorf("%s %s %.200q refused while bodies are read: %+v",
r.method, r.target, recorded.Body, got)
}
})
}
}
func TestOrdinaryDrupalRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "drupal-requests.txt", "drupal")
}
// TestOrdinaryForgejoRequestsPass checks Forgejo's recorded requests at the
// defaults only: with SWW_APPS unset, and on example.com, where they were
// recorded, while SWW_APPS lists only forgejo for it. While bodies are read,
// the Core Rule Set still refuses the file and branch names some of them
// carry in a form or JSON body, as it does gitea's
// (TestGiteaBodiesTheCoreRuleSetRefuses).
func TestOrdinaryForgejoRequestsPass(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
requests := recordedRequests(t, "forgejo-requests.txt")
forgejoOnly := map[string][]string{"example.com": {forgejo}}
for apps, crs := range map[string]*coreruleset.CoreRuleSet{
"SWW_APPS unset": atDefaults(t),
"SWW_APPS=example.com:forgejo": servingApps(t, forgejoOnly),
} {
for _, recorded := range requests {
r := request{recorded.Method, recorded.Target, recorded.Headers}
if got, _ := inspectBody(t, crs, r, recorded.Body); got.Score >= threshold {
t.Errorf("%s: %s %s refused: %+v", apps, r.method, r.target, got)
}
}
}
}
func TestOrdinaryMastodonRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "mastodon-requests.txt", "mastodon")
}
func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "mediawiki-requests.txt", mediawiki)
}
func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "nextcloud-requests.txt", nextcloud)
}
func TestOrdinaryVaultwardenRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "vaultwarden-requests.txt", "vaultwarden")
}
func TestOrdinaryWordPressRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "wordpress-requests.txt", wordpress)
}