check / check (push) Failing after 23m49s
`smallwebwaf` with no command now prints the commands and exits with status 2. The image runs rproxy, or servewaf with SMALLWEBWAF_COMMAND=servewaf. The passing-on code, its settings and its log fields and metric go. internal/proxy keeps the checks, answers, reports and own endpoints; its tests run through the real rproxy. The documents describe the pair. Judgement call: clients.json keeps token_refused, since state files refuse unknown fields. Deviation: script/gitea-requests sets SWWAF_LOG_REQUEST_HEADERS, to label requests. Model: opus-5-5
603 lines
16 KiB
Go
603 lines
16 KiB
Go
package proxy_test
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"maps"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
"sneak.berlin/go/smallwebwaf/internal/lookup"
|
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
|
"sneak.berlin/go/smallwebwaf/internal/rules"
|
|
)
|
|
|
|
const (
|
|
// waitLimit bounds how long a test waits for what should happen.
|
|
waitLimit = 10 * time.Second
|
|
// pollInterval is how often a test looks for a log line.
|
|
pollInterval = 10 * time.Millisecond
|
|
// localhost is where every test server listens, and so the address rproxy
|
|
// sees each test's requests come from.
|
|
localhost = "127.0.0.1"
|
|
// requestType is the type that marks a request log line.
|
|
requestType = "request"
|
|
// protocol is the protocol of every question.
|
|
protocol = "HTTP/1.1"
|
|
// trustLocalhost trusts the address every test connects from, and a
|
|
// network for proxies in front of it.
|
|
trustLocalhost = localhost + "/32,10.0.0.0/8"
|
|
// appHost is the host every test asks for.
|
|
appHost = "app.example"
|
|
// client is the client's address, as a proxy names it.
|
|
client = "203.0.113.9"
|
|
// forwardedFor is the header that lists the client and its proxies,
|
|
// and forwardedProto the one that gives the scheme the client used.
|
|
forwardedFor = "X-Forwarded-For"
|
|
forwardedProto = "X-Forwarded-Proto"
|
|
// secure is the scheme a client reached traefik with, and plain the one
|
|
// rproxy serves.
|
|
secure = "https"
|
|
plain = "http"
|
|
)
|
|
|
|
// The settings the tests set.
|
|
const (
|
|
mode = "SWWAF_MODE"
|
|
trustedProxies = "SWRP_TRUSTED_PROXIES"
|
|
ipv6GroupPrefix = "SWWAF_IPV6_GROUP_PREFIX"
|
|
maxTrackedClients = "SWWAF_MAX_TRACKED_CLIENTS"
|
|
allowNets = "SWWAF_ALLOW_NETS"
|
|
rateLimitExemptNets = "SWWAF_RATE_LIMIT_EXEMPT_NETS"
|
|
denyNets = "SWWAF_DENY_NETS"
|
|
rateLimitPerMinute = "SWWAF_RATE_LIMIT_PER_MINUTE"
|
|
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
|
rateLimitExemptPaths = "SWWAF_RATE_LIMIT_EXEMPT_PATHS"
|
|
lookupSource = "SWWAF_LOOKUP_SOURCE"
|
|
lookupDBPath = "SWWAF_LOOKUP_DB_PATH"
|
|
lookupTimeout = "SWWAF_LOOKUP_TIMEOUT"
|
|
addLookupHeaders = "SWWAF_ADD_LOOKUP_HEADERS"
|
|
deniedCountries = "SWWAF_DENIED_COUNTRIES"
|
|
allowedCountries = "SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES"
|
|
banResponse = "SWWAF_BAN_RESPONSE"
|
|
limitBanDuration = "SWWAF_LIMIT_BAN_DURATION"
|
|
limitBanRepeatWindow = "SWWAF_LIMIT_BAN_REPEAT_WINDOW"
|
|
maxBanDuration = "SWWAF_MAX_BAN_DURATION"
|
|
maxBans = "SWWAF_MAX_BANS"
|
|
banScopeV4Prefix = "SWWAF_BAN_SCOPE_V4_PREFIX"
|
|
instanceName = "SWWAF_INSTANCE_NAME"
|
|
logRequestHeaders = "SWWAF_LOG_REQUEST_HEADERS"
|
|
attackBanDuration = "SWWAF_ATTACK_BAN_DURATION"
|
|
rulesDir = "SWWAF_RULES_DIR"
|
|
wafMode = "SWWAF_WAF_MODE"
|
|
)
|
|
|
|
// off is the value that switches a setting off.
|
|
const off = "off"
|
|
|
|
// on is the value that switches a setting that takes true or false on.
|
|
const on = "true"
|
|
|
|
// output collects what smallwebwaf writes on stdout.
|
|
type output struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
// Write adds lines smallwebwaf writes.
|
|
func (o *output) Write(p []byte) (int, error) {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
|
|
return o.buf.Write(p)
|
|
}
|
|
|
|
// text returns everything written so far.
|
|
func (o *output) text() string {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
|
|
return o.buf.String()
|
|
}
|
|
|
|
// lines returns every line written so far, decoded.
|
|
func (o *output) lines(t *testing.T) []map[string]any {
|
|
t.Helper()
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
|
|
var lines []map[string]any
|
|
|
|
for text := range strings.Lines(o.buf.String()) {
|
|
var line map[string]any
|
|
|
|
err := json.Unmarshal([]byte(text), &line)
|
|
if err != nil {
|
|
t.Fatalf("output line %q is not JSON: %v", text, err)
|
|
}
|
|
|
|
lines = append(lines, line)
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
// logLine is a request log line, as typed fields and as the JSON object
|
|
// it was written as.
|
|
type logLine struct {
|
|
requestlog.Line
|
|
|
|
fields map[string]any
|
|
}
|
|
|
|
// requestLines waits for count request log lines and returns them.
|
|
func (o *output) requestLines(t *testing.T, count int) []logLine {
|
|
t.Helper()
|
|
|
|
deadline := time.Now().Add(waitLimit)
|
|
for time.Now().Before(deadline) {
|
|
var found []logLine
|
|
|
|
for _, fields := range o.lines(t) {
|
|
if fields["type"] == requestType {
|
|
found = append(found, decodeLine(t, fields))
|
|
}
|
|
}
|
|
|
|
if len(found) >= count {
|
|
return found
|
|
}
|
|
|
|
time.Sleep(pollInterval)
|
|
}
|
|
|
|
t.Fatalf("fewer than %d request log lines after %s", count, waitLimit)
|
|
|
|
return nil
|
|
}
|
|
|
|
// requestLine waits for the request log line of a test's one request.
|
|
func (o *output) requestLine(t *testing.T) logLine {
|
|
t.Helper()
|
|
|
|
return o.requestLines(t, 1)[0]
|
|
}
|
|
|
|
// endedLines waits for count requests that went through rproxy, or to servewaf's
|
|
// own endpoints, to have ended, and returns the last log line of each, in the
|
|
// order servewaf took them. A question's last line is the one its report writes.
|
|
func (o *output) endedLines(t *testing.T, count int) []logLine {
|
|
t.Helper()
|
|
|
|
deadline := time.Now().Add(waitLimit)
|
|
for time.Now().Before(deadline) {
|
|
found := o.lastLines(t)
|
|
|
|
ended := len(found) >= count
|
|
for _, line := range found[:min(count, len(found))] {
|
|
if line.ServeWAFLine == requestlog.ServeWAFLineDecision {
|
|
ended = false
|
|
}
|
|
}
|
|
|
|
if ended {
|
|
return found
|
|
}
|
|
|
|
time.Sleep(pollInterval)
|
|
}
|
|
|
|
t.Fatalf("fewer than %d requests have ended after %s", count, waitLimit)
|
|
|
|
return nil
|
|
}
|
|
|
|
// lastLines returns the last request log line written so far of each request,
|
|
// in the order servewaf took them.
|
|
func (o *output) lastLines(t *testing.T) []logLine {
|
|
t.Helper()
|
|
|
|
var found []logLine
|
|
|
|
places := map[string]int{}
|
|
|
|
for _, fields := range o.lines(t) {
|
|
if fields["type"] != requestType {
|
|
continue
|
|
}
|
|
|
|
line := decodeLine(t, fields)
|
|
|
|
place, seen := places[line.RequestID]
|
|
if seen {
|
|
found[place] = line
|
|
|
|
continue
|
|
}
|
|
|
|
places[line.RequestID] = len(found)
|
|
found = append(found, line)
|
|
}
|
|
|
|
return found
|
|
}
|
|
|
|
// endedLine waits for a test's one request to have ended, and returns its last
|
|
// log line.
|
|
func (o *output) endedLine(t *testing.T) logLine {
|
|
t.Helper()
|
|
|
|
return o.endedLines(t, 1)[0]
|
|
}
|
|
|
|
// decodeLine reads a request log line's fields into a logLine.
|
|
func decodeLine(t *testing.T, fields map[string]any) logLine {
|
|
t.Helper()
|
|
|
|
encoded, err := json.Marshal(fields)
|
|
if err != nil {
|
|
t.Fatalf("encode %v: %v", fields, err)
|
|
}
|
|
|
|
line := logLine{fields: fields}
|
|
|
|
err = json.Unmarshal(encoded, &line.Line)
|
|
if err != nil {
|
|
t.Fatalf("decode %s: %v", encoded, err)
|
|
}
|
|
|
|
return line
|
|
}
|
|
|
|
// startApp starts app as the app rproxy passes requests to.
|
|
func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
|
|
t.Helper()
|
|
|
|
server := httptest.NewServer(app)
|
|
t.Cleanup(server.Close)
|
|
|
|
return server
|
|
}
|
|
|
|
// startProxy starts servewaf, and rproxy in front of the app at appURL, each with
|
|
// the settings in env on top of the defaults, and returns where rproxy listens
|
|
// and what servewaf writes.
|
|
func startProxy(t *testing.T, appURL string, env map[string]string) (string, *output) {
|
|
t.Helper()
|
|
|
|
return startProxyWithGeoJS(t, appURL, "", env)
|
|
}
|
|
|
|
// startProxyWithGeoJS is startProxy with clients' AS numbers and
|
|
// countries looked up at geojsURL.
|
|
func startProxyWithGeoJS(
|
|
t *testing.T, appURL, geojsURL string, env map[string]string,
|
|
) (string, *output) {
|
|
t.Helper()
|
|
|
|
addr, out, _ := startProxyWithClock(t, appURL, geojsURL, time.Now, env)
|
|
|
|
return addr, out
|
|
}
|
|
|
|
// startProxyWithClock is startProxyWithGeoJS with requests counted and
|
|
// bans made by the time now tells, and returns servewaf's server as well, whose
|
|
// Addr is where servewaf listens. Unless env sets SWWAF_RULES_DIR, it is an empty
|
|
// directory, of no rules, and unless it sets SWWAF_INSTANCE_NAME, that is app,
|
|
// the label instance of every metric.
|
|
func startProxyWithClock(
|
|
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
|
env map[string]string,
|
|
) (string, *output, *proxy.Server) {
|
|
t.Helper()
|
|
|
|
addr, out, server, _ := startProxyWithAlerts(t, appURL, geojsURL, now, env)
|
|
|
|
return addr, out, server
|
|
}
|
|
|
|
// startProxyWithAlerts is startProxyWithClock, and returns the queue of
|
|
// the alerts servewaf raises as well, as newServeWAF makes them.
|
|
func startProxyWithAlerts(
|
|
t *testing.T, appURL, geojsURL string, now func() time.Time,
|
|
env map[string]string,
|
|
) (string, *output, *proxy.Server, *alerts.Queue) {
|
|
t.Helper()
|
|
|
|
server, out, alertQueue := newServeWAF(t, geojsURL, now, env)
|
|
serve(t, server)
|
|
|
|
return startRProxy(t, server.Addr, appURL, env), out, server, alertQueue
|
|
}
|
|
|
|
// serve serves server on a port of its own until the test ends, and sets its
|
|
// Addr to where it listens.
|
|
func serve(t *testing.T, server *proxy.Server) {
|
|
t.Helper()
|
|
|
|
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
|
|
server.Addr = listener.Addr().String()
|
|
|
|
go func() {
|
|
_ = server.Serve(listener)
|
|
}()
|
|
|
|
t.Cleanup(func() {
|
|
_ = server.Close()
|
|
})
|
|
}
|
|
|
|
// startRProxy starts rproxy in front of the app at appURL, asking the servewaf
|
|
// at serveWAFAddr, with the settings in env on top of the defaults, and returns
|
|
// where it listens. What it writes is dropped. SWRP_TIMEOUT_MS is a minute unless
|
|
// env sets it, so that on a busy host SWRP_DEFAULT_DISPOSITION does not decide.
|
|
func startRProxy(
|
|
t *testing.T, serveWAFAddr, appURL string, env map[string]string,
|
|
) string {
|
|
t.Helper()
|
|
|
|
settings := map[string]string{
|
|
"SWRP_WAF_BACKEND": "http://" + serveWAFAddr,
|
|
"SWRP_UPSTREAM_URL": appURL,
|
|
"SWRP_TIMEOUT_MS": "60000",
|
|
}
|
|
maps.Copy(settings, env)
|
|
|
|
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) {
|
|
value, ok := settings[name]
|
|
|
|
return value, ok
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("settings %v: %v", settings, err)
|
|
}
|
|
|
|
server := rproxy.New(rproxy.Params{
|
|
Config: cfg,
|
|
RequestLog: io.Discard,
|
|
ProcessLog: requestlog.NewProcessLogger(io.Discard, "rproxy", slog.LevelInfo),
|
|
})
|
|
|
|
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
|
|
go func() {
|
|
_ = server.Serve(listener)
|
|
}()
|
|
|
|
t.Cleanup(func() {
|
|
_ = server.Close()
|
|
})
|
|
|
|
return listener.Addr().String()
|
|
}
|
|
|
|
// newServeWAF makes servewaf's server, without starting it, and returns it, what
|
|
// it writes, and the queue of the alerts it raises, as the settings in env make
|
|
// it. No alert is sent from the queue: they wait in it, for the test to look at.
|
|
// With no geojsURL, there is no stand-in for GeoJS to look clients up at, and
|
|
// SWWAF_LOOKUP_SOURCE is off unless env sets it. While it is file, the lookup
|
|
// database SWWAF_LOOKUP_DB_PATH names is read. Clients are checked with AbuseIPDB
|
|
// at abuseIPDBURL while env sets SWWAF_ABUSEIPDB_KEY. SWWAF_WAF_MODE is off unless
|
|
// env sets it, so that only the tests of the Core Rule Set have their requests
|
|
// inspected by it.
|
|
func newServeWAF(
|
|
t *testing.T, geojsURL string, now func() time.Time, env map[string]string,
|
|
) (*proxy.Server, *output, *alerts.Queue) {
|
|
t.Helper()
|
|
|
|
return newServeWAFWith(t, proxy.NewServeWAF, geojsURL, now, env)
|
|
}
|
|
|
|
// newServeWAFWith is newServeWAF for the server build makes.
|
|
func newServeWAFWith(
|
|
t *testing.T, build func(proxy.Params) *proxy.Server,
|
|
geojsURL string, now func() time.Time, env map[string]string,
|
|
) (*proxy.Server, *output, *alerts.Queue) {
|
|
t.Helper()
|
|
|
|
settings := map[string]string{
|
|
rulesDir: t.TempDir(), instanceName: "app", wafMode: off,
|
|
}
|
|
if geojsURL == "" {
|
|
settings[lookupSource] = off
|
|
}
|
|
|
|
maps.Copy(settings, env)
|
|
|
|
cfg, err := config.FromEnvironment(func(name string) (string, bool) {
|
|
value, ok := settings[name]
|
|
|
|
return value, ok
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("settings %v: %v", settings, err)
|
|
}
|
|
|
|
out := &output{}
|
|
processLog := requestlog.NewProcessLogger(out, cfg.InstanceName, cfg.LogLevel)
|
|
|
|
ruleFiles, err := rules.Load(rules.Params{
|
|
Dir: cfg.RulesDir, Enabled: cfg.RulesEnabled, ProcessLog: processLog,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("rule files: %v", err)
|
|
}
|
|
|
|
alertQueue := alerts.New(alerts.Params{
|
|
WebhookURL: cfg.AlertWebhookURL,
|
|
Events: cfg.AlertEvents,
|
|
Cooldown: cfg.AlertCooldown,
|
|
MaxPerHour: cfg.AlertMaxPerHour,
|
|
Instance: cfg.InstanceName,
|
|
Now: now,
|
|
ProcessLog: processLog,
|
|
})
|
|
|
|
var lookupFile *lookup.File
|
|
|
|
if cfg.LookupSource == fileSource {
|
|
lookupFile, err = lookup.OpenFile(lookup.FileParams{
|
|
Path: cfg.LookupDBPath, Now: now, ProcessLog: processLog, Alerts: alertQueue,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("lookup database: %v", err)
|
|
}
|
|
}
|
|
|
|
server := build(proxy.Params{
|
|
Config: cfg,
|
|
RequestLog: out,
|
|
ProcessLog: processLog,
|
|
GeoJSURL: geojsURL,
|
|
AbuseIPDBURL: abuseIPDBURL,
|
|
LookupFile: lookupFile,
|
|
Now: now,
|
|
Rules: ruleFiles,
|
|
Alerts: alertQueue,
|
|
})
|
|
|
|
return server, out, alertQueue
|
|
}
|
|
|
|
// newClient returns an HTTP client that sends requests as they are made,
|
|
// with no compression of its own.
|
|
func newClient(t *testing.T) *http.Client {
|
|
t.Helper()
|
|
|
|
transport := &http.Transport{DisableCompression: true}
|
|
t.Cleanup(transport.CloseIdleConnections)
|
|
|
|
return &http.Client{Transport: transport}
|
|
}
|
|
|
|
// answer is a response as a test reads it: the status, the headers, as
|
|
// much of the body as arrived, and the error that ended the reading, nil
|
|
// when the whole body arrived.
|
|
type answer struct {
|
|
status int
|
|
header http.Header
|
|
body []byte
|
|
err error
|
|
}
|
|
|
|
// readAnswer reads all of res, and closes its body.
|
|
func readAnswer(res *http.Response) answer {
|
|
body, err := io.ReadAll(res.Body)
|
|
_ = res.Body.Close()
|
|
|
|
return answer{status: res.StatusCode, header: res.Header, body: body, err: err}
|
|
}
|
|
|
|
// newRequest makes a request for path to addr, where rproxy or servewaf listens.
|
|
func newRequest(t *testing.T, method, addr, path string, body io.Reader) *http.Request {
|
|
t.Helper()
|
|
|
|
req, err := http.NewRequestWithContext(t.Context(), method, "http://"+addr+path, body)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
|
|
return req
|
|
}
|
|
|
|
// do sends req and reads the answer.
|
|
func do(t *testing.T, req *http.Request) answer {
|
|
t.Helper()
|
|
|
|
res, err := newClient(t).Do(req)
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", req.Method, req.URL.Path, err)
|
|
}
|
|
|
|
return readAnswer(res)
|
|
}
|
|
|
|
// get sends a GET request for path to addr.
|
|
func get(t *testing.T, addr, path string) answer {
|
|
t.Helper()
|
|
|
|
return do(t, newRequest(t, http.MethodGet, addr, path, http.NoBody))
|
|
}
|
|
|
|
// dial opens a connection to addr, for requests the HTTP client cannot make,
|
|
// such as one that stops sending halfway.
|
|
func dial(t *testing.T, addr string) net.Conn {
|
|
t.Helper()
|
|
|
|
conn, err := (&net.Dialer{}).DialContext(t.Context(), "tcp", addr)
|
|
if err != nil {
|
|
t.Fatalf("dial %s: %v", addr, err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_ = conn.Close()
|
|
})
|
|
|
|
return conn
|
|
}
|
|
|
|
// send writes text to conn.
|
|
func send(t *testing.T, conn net.Conn, text string) {
|
|
t.Helper()
|
|
|
|
_, err := io.WriteString(conn, text)
|
|
if err != nil {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
}
|
|
|
|
// readResponse reads the answer to a request sent on conn.
|
|
func readResponse(t *testing.T, conn net.Conn) answer {
|
|
t.Helper()
|
|
|
|
err := conn.SetReadDeadline(time.Now().Add(waitLimit))
|
|
if err != nil {
|
|
t.Fatalf("set read deadline: %v", err)
|
|
}
|
|
|
|
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
|
if err != nil {
|
|
t.Fatalf("read response: %v", err)
|
|
}
|
|
|
|
return readAnswer(res)
|
|
}
|
|
|
|
// wantLine checks the request log line's status and action.
|
|
func wantLine(t *testing.T, line logLine, status int, action string) {
|
|
t.Helper()
|
|
|
|
if line.Status != status || line.Action != action {
|
|
t.Errorf("log line has status %d and action %q, want %d and %q",
|
|
line.Status, line.Action, status, action)
|
|
}
|
|
}
|
|
|
|
// wantStatus checks an answer's status.
|
|
func wantStatus(t *testing.T, got answer, status int) {
|
|
t.Helper()
|
|
|
|
if got.status != status {
|
|
t.Errorf("status %d, want %d", got.status, status)
|
|
}
|
|
}
|