Files
smallwebwaf/internal/coreruleset/recorded_test.go
T
clawbot 6fa0865cdb
check / check (push) Waiting to run
Core Rule Set: gitea and Forgejo file and branch names in bodies pass (closes #346)
While SWW_WAF_BODY_LIMIT is set, these body fields skip only the rules
that refused them, on their own paths:

- tree_path, commit_summary and content on the web editor's pages:
  edit, new file, delete, upload, patch, cherry-pick and preview;
- fields that name a branch, for Windows commands and command names:
  new_branch_name, old_branch_name, old_ref_name, from, to, head, base,
  branch and new_branch; base also when a pull request is edited
  through the API;
- message of one file or several changed through the API, for system
  files and Windows commands.

None applies when the query has a parameter of the same name. The
requests were recorded from gitea 1.27.3 and Forgejo 17.0.1.

Model: opus-5-5
2026-11-12 02:50:58 +00:00

285 lines
7.5 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"
)
// appsUnset labels the checks made while SWW_APPS is unset.
const appsUnset = "SWW_APPS unset"
// 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
}
// TestOrdinaryGiteaRequestsPass checks gitea's recorded requests with
// SWW_APPS unset, and on giteaHost while SWW_APPS lists only gitea for it.
func TestOrdinaryGiteaRequestsPass(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
requests := giteaRequests(t)
giteaOnly := map[string][]string{giteaHost: {gitea}}
for apps, crs := range map[string]*coreruleset.CoreRuleSet{
appsUnset: atDefaults(t),
"SWW_APPS=" + giteaHost + ":" + gitea: servingApps(t, giteaOnly),
} {
for _, r := range requests {
got := inspect(t, crs, withHost(r, giteaHost))
if got.Score >= threshold {
t.Errorf(
"%s: %s %q refused: %+v",
apps,
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: with SWW_APPS unset, and
// 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. Both
// times, they are checked at the defaults and while bodies are read. 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 for each setting of
// SWW_APPS.
func wantRecordedRequestsPass(t *testing.T, name, app string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
requests := recordedRequests(t, name)
for setting, apps := range map[string]map[string][]string{
appsUnset: nil,
"SWW_APPS lists only " + app: {"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 requests {
t.Run(
setting+": "+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 a new wiki page whose title and text name
// commands and files, as it does such text in gitea's comments
// (TestGiteaBodiesTheCoreRuleSetRefuses). The requests that name a file or a
// branch in a body are checked while bodies are read in
// TestForgejoNamesInBodiesPass.
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{
appsUnset: 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,
)
}
}
}
}
// TestGiteaNamesInBodiesPass checks gitea's recorded requests that name a
// file or a branch in a body, while bodies are read too.
func TestGiteaNamesInBodiesPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "gitea-names-in-bodies.txt", gitea)
}
// TestForgejoNamesInBodiesPass is TestGiteaNamesInBodiesPass for Forgejo.
func TestForgejoNamesInBodiesPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "forgejo-names-in-bodies.txt", forgejo)
}
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)
}