check / check (push) Waiting to run
The package passes nothing to an app since rproxy took that over, so it is named after the command it serves. Its constructor is New, like rproxy's. The rproxy tests call their stand-in for servewaf standIn, since a local servewaf would hide the package. No change of behaviour. Model: opus-5-5
418 lines
13 KiB
Go
418 lines
13 KiB
Go
package servewaf_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/netip"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/bans"
|
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
"sneak.berlin/go/smallwebwaf/internal/state"
|
|
)
|
|
|
|
const (
|
|
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
|
// adminSecret is the SWWAF_ADMIN_TOKEN the tests set, and adminBearer
|
|
// how a request carries it.
|
|
adminSecret = "fedcba9876543210fedcba9876543210"
|
|
adminBearer = "Bearer " + adminSecret
|
|
// banOtherClient is the body of a request to ban otherClient for an
|
|
// hour.
|
|
banOtherClient = `{"netblock": "` + otherClient + `", "duration": "1h", ` +
|
|
`"reason": "probes for logins"}`
|
|
)
|
|
|
|
func TestAdminEndpointsAreOffWhileTheTokenIsUnset(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The metrics token is set, and opens none of them.
|
|
s, clk, server := startWithClock(t, "", map[string]string{metricsToken: token})
|
|
server.Ledger.BanForLimit(netip.MustParsePrefix(otherClient+"/32"), clk.Now(),
|
|
bans.Notes{})
|
|
before := server.Ledger.Snapshot()
|
|
|
|
// An empty token does not match the unset one either.
|
|
for _, authorization := range []string{adminBearer, bearer, "Bearer ", ""} {
|
|
for _, e := range adminEndpoints() {
|
|
s.adminRequest(authorization, e.method, e.path, e.body, http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
if after := server.Ledger.Snapshot(); !reflect.DeepEqual(after, before) {
|
|
t.Errorf("the bans are now\n%+v\nwant them unchanged\n%+v", after, before)
|
|
}
|
|
}
|
|
|
|
func TestAdminEndpointsNeedTheAdminToken(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _, server := startWithClock(t, "", map[string]string{
|
|
adminToken: adminSecret,
|
|
metricsToken: token,
|
|
})
|
|
|
|
// Listing the bans, banning otherClient, lifting that ban, and asking
|
|
// about otherClient, in that order. Without the admin token, with the
|
|
// metrics token, or with one that differs, each is refused, and
|
|
// changes nothing; with the admin token, it is answered.
|
|
for _, e := range adminEndpoints() {
|
|
before := server.Ledger.Snapshot()
|
|
|
|
for _, authorization := range []string{
|
|
"", bearer, "Bearer " + strings.ToUpper(adminSecret), "Basic " + adminSecret,
|
|
} {
|
|
got := s.adminRequest(authorization, e.method, e.path, e.body,
|
|
http.StatusUnauthorized)
|
|
if got.header.Get("WWW-Authenticate") != "Bearer" {
|
|
t.Errorf("%s %s with %q was answered without WWW-Authenticate: Bearer",
|
|
e.method, e.path, authorization)
|
|
}
|
|
}
|
|
|
|
if after := server.Ledger.Snapshot(); !reflect.DeepEqual(after, before) {
|
|
t.Errorf("%s %s without the token changed the bans to\n%+v\nfrom\n%+v",
|
|
e.method, e.path, after, before)
|
|
}
|
|
|
|
got := s.admin(e.method, e.path, e.body, http.StatusOK)
|
|
if got.header.Get("Content-Type") != "application/json" {
|
|
t.Errorf("%s %s answered %q", e.method, e.path, got.header.Get("Content-Type"))
|
|
}
|
|
}
|
|
|
|
// Any other request under /_smallwebwaf/ is not found.
|
|
for _, e := range []adminEndpoint{
|
|
{http.MethodPut, servewaf.BansPath, banOtherClient},
|
|
{http.MethodDelete, servewaf.BansPath, ""},
|
|
{http.MethodGet, servewaf.BansPath + "/" + otherClient, ""},
|
|
{http.MethodPost, servewaf.ClientsPath + otherClient, ""},
|
|
{http.MethodGet, strings.TrimSuffix(servewaf.ClientsPath, "/"), ""},
|
|
} {
|
|
s.admin(e.method, e.path, e.body, http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
func TestBanAddedListedAndLiftedThroughTheEndpoints(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, clk, _ := startWithClock(t, "", map[string]string{
|
|
adminToken: adminSecret,
|
|
banScopeV4Prefix: "24",
|
|
})
|
|
|
|
// A ban on otherClient bans the /24 a ban on that client covers, so it
|
|
// refuses client too, for an hour.
|
|
start := clk.Now()
|
|
expires := start.Add(time.Hour)
|
|
want := state.BanEntry{
|
|
Netblock: netip.MustParsePrefix("203.0.113.0/24"),
|
|
Start: start,
|
|
Expires: &expires,
|
|
Cause: bans.CauseAdmin,
|
|
Reason: "probes for logins",
|
|
}
|
|
|
|
wantBans(t, s.admin(http.MethodPost, servewaf.BansPath, banOtherClient, http.StatusOK),
|
|
want)
|
|
|
|
line := s.get(client, http.StatusForbidden, requestlog.ActionBanned)
|
|
if line.BanExpires != requestlog.FormatTime(expires) {
|
|
t.Errorf("the ban ends at %s, want %s", line.BanExpires, expires)
|
|
}
|
|
|
|
// Its notes count the request it refused.
|
|
want.Notes.Requests, want.Notes.Refused = 1, 1
|
|
|
|
wantBans(t, s.admin(http.MethodGet, servewaf.BansPath, "", http.StatusOK), want)
|
|
|
|
// Ten minutes on, lifting the bans on client lifts that one, which is
|
|
// kept, marked lifted.
|
|
clk.advance(10 * time.Minute)
|
|
|
|
lifted := clk.Now()
|
|
want.Lifted = &lifted
|
|
|
|
wantBans(t, s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "",
|
|
http.StatusOK), want)
|
|
s.get(client, http.StatusOK, requestlog.ActionForward)
|
|
wantBans(t, s.admin(http.MethodGet, servewaf.BansPath, "", http.StatusOK), want)
|
|
|
|
// No ban on it is active any more.
|
|
s.admin(http.MethodDelete, servewaf.BansPath+"/"+client, "", http.StatusNotFound)
|
|
}
|
|
|
|
func TestBanToAddGivesItsNetblockAndDuration(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, clk, _ := startWithClock(t, "", map[string]string{
|
|
adminToken: adminSecret,
|
|
banScopeV4Prefix: "24",
|
|
})
|
|
start := clk.Now()
|
|
|
|
for _, tc := range []struct {
|
|
netblock, duration string
|
|
want string
|
|
length time.Duration // 0 for a permanent ban
|
|
}{
|
|
// An address stands for the netblock a ban on that client covers.
|
|
{client, "7d", "203.0.113.0/24", 7 * 24 * time.Hour},
|
|
{"::ffff:198.51.100.7", "90m", "198.51.100.0/24", 90 * time.Minute},
|
|
{"2001:db8:5::1", "permanent", "2001:db8:5::/64", 0},
|
|
// A netblock stands for itself, its bits past its length cleared.
|
|
{"198.51.100.7/16", "1h", "198.51.0.0/16", time.Hour},
|
|
{"2001:db8:6::/48", "1h", "2001:db8:6::/48", time.Hour},
|
|
} {
|
|
// Whitespace may follow the object.
|
|
body := `{"netblock": "` + tc.netblock + `", "duration": "` + tc.duration + `"}` +
|
|
"\r\n"
|
|
want := state.BanEntry{
|
|
Netblock: netip.MustParsePrefix(tc.want), Start: start, Cause: bans.CauseAdmin,
|
|
}
|
|
|
|
if tc.length != 0 {
|
|
expires := start.Add(tc.length)
|
|
want.Expires = &expires
|
|
}
|
|
|
|
wantBans(t, s.admin(http.MethodPost, servewaf.BansPath, body, http.StatusOK), want)
|
|
}
|
|
}
|
|
|
|
func TestBanToAddThatCannotBeReadIsRefused(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _, server := startWithClock(t, "", map[string]string{adminToken: adminSecret})
|
|
|
|
for _, tc := range []struct{ body, want string }{
|
|
{"", "the body is not a JSON object of netblock, duration and reason: EOF"},
|
|
{"netblock=203.0.113.9", "the body is not a JSON object"},
|
|
{
|
|
`{"netblock": "203.0.113.9", "duration": "1h", "until": "2027"}`,
|
|
`unknown field "until"`,
|
|
},
|
|
{
|
|
`{"netblock": "203.0.113", "duration": "1h"}`,
|
|
`netblock "203.0.113" is not an address or a netblock`,
|
|
},
|
|
// A client's address is looked up as IPv4, so a ban on an
|
|
// IPv4-mapped netblock would refuse nothing.
|
|
{
|
|
`{"netblock": "::ffff:203.0.113.0/120", "duration": "1h"}`,
|
|
`netblock "::ffff:203.0.113.0/120" is IPv4-mapped`,
|
|
},
|
|
// Read as an address, its zone would be "x/48", and its ban on the
|
|
// /64 around it.
|
|
{
|
|
`{"netblock": "2001:db8::1%x/48", "duration": "1h"}`,
|
|
`netblock "2001:db8::1%x/48" has a zone`,
|
|
},
|
|
{
|
|
`{"netblock": "fe80::1%eth0", "duration": "1h"}`,
|
|
`netblock "fe80::1%eth0" has a zone`,
|
|
},
|
|
// Anything but whitespace after the object.
|
|
{
|
|
`{"netblock": "203.0.113.9", "duration": "1h"}` +
|
|
`{"netblock": "198.51.100.0/24", "duration": "1h"}`,
|
|
"more follows the object",
|
|
},
|
|
{`{"netblock": "203.0.113.9", "duration": "1h"} x`, "more follows the object"},
|
|
{`{"duration": "1h"}`, `netblock "" is not an address or a netblock`},
|
|
{`{"netblock": "203.0.113.9"}`, `duration "" is not a duration above zero`},
|
|
{
|
|
`{"netblock": "203.0.113.9", "duration": "off"}`,
|
|
`duration "off" is not a duration above zero`,
|
|
},
|
|
{
|
|
`{"netblock": "203.0.113.9", "duration": "0s"}`,
|
|
`duration "0s" is not a duration above zero`,
|
|
},
|
|
{
|
|
`{"netblock": "203.0.113.9", "duration": "forever"}`,
|
|
`duration "forever" is not a duration above zero, such as 1h or 7d, ` +
|
|
`or permanent`,
|
|
},
|
|
// Over the 4 KiB read of a body, even when the object comes first.
|
|
{
|
|
`{"netblock": "203.0.113.9", "duration": "1h", "reason": "` +
|
|
strings.Repeat("x", 4<<10) + `"}`,
|
|
"request body too large",
|
|
},
|
|
{
|
|
`{"netblock": "203.0.113.9", "duration": "1h"}` + strings.Repeat(" ", 4<<10),
|
|
"request body too large",
|
|
},
|
|
} {
|
|
got := s.admin(http.MethodPost, servewaf.BansPath, tc.body, http.StatusBadRequest)
|
|
if !strings.Contains(string(got.body), tc.want) {
|
|
t.Errorf("%.80s was answered %q, want it to say %q", tc.body, got.body, tc.want)
|
|
}
|
|
}
|
|
|
|
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
|
t.Errorf("the ledger holds %+v, want no ban", held)
|
|
}
|
|
}
|
|
|
|
func TestClientEndpointShowsTheClientAndItsBans(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, clk, _ := startWithClock(t, "", map[string]string{
|
|
adminToken: adminSecret,
|
|
rateLimitPerMinute: "2",
|
|
})
|
|
start := clk.Now()
|
|
|
|
// Two of otherClient's requests are let through; the third breaks the
|
|
// limit of two a minute, and bans it.
|
|
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
|
s.get(otherClient, http.StatusOK, requestlog.ActionForward)
|
|
s.get(otherClient, http.StatusForbidden, requestlog.ActionRateLimited)
|
|
|
|
// Asked about by its address in IPv6 form too.
|
|
for _, addr := range []string{otherClient, "::ffff:" + otherClient} {
|
|
var got struct {
|
|
Client *ratelimit.Client `json:"client"`
|
|
Bans []state.BanEntry `json:"bans"`
|
|
}
|
|
|
|
decode(t, s.admin(http.MethodGet, servewaf.ClientsPath+addr, "", http.StatusOK), &got)
|
|
|
|
if got.Client == nil {
|
|
t.Fatalf("%s: no client", addr)
|
|
}
|
|
|
|
history := got.Client.History
|
|
if got.Client.Client != netip.MustParsePrefix(otherClient+"/32") ||
|
|
history.Requests != 3 || history.Forwarded != 2 || history.Refused != 1 ||
|
|
history.Offences.Limit != 1 || !history.FirstSeen.Equal(start) {
|
|
t.Errorf("%s: client %+v", addr, got.Client)
|
|
}
|
|
|
|
if len(got.Bans) != 1 || got.Bans[0].Cause != bans.CauseLimit ||
|
|
got.Bans[0].Reason != "requests per minute over the limit of 2" ||
|
|
got.Bans[0].Notes.Count != 3 {
|
|
t.Errorf("%s: bans %+v, want the one for the broken limit", addr, got.Bans)
|
|
}
|
|
}
|
|
|
|
// Of an address no request came from and no ban covers, nothing is
|
|
// known.
|
|
got := s.admin(http.MethodGet, servewaf.ClientsPath+"198.51.100.99", "", http.StatusOK)
|
|
if string(got.body) != "{\n \"client\": null,\n \"bans\": []\n}\n" {
|
|
t.Errorf("an unknown client is answered\n%s", got.body)
|
|
}
|
|
|
|
s.admin(http.MethodGet, servewaf.ClientsPath+"203.0.113", "", http.StatusBadRequest)
|
|
s.admin(http.MethodDelete, servewaf.BansPath+"/203.0.113.0/24", "",
|
|
http.StatusBadRequest)
|
|
}
|
|
|
|
func TestAdminEndpointsNeedTheTokenInObserveMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s, _, server := startWithClock(t, "", map[string]string{
|
|
adminToken: adminSecret,
|
|
mode: observe,
|
|
})
|
|
|
|
for _, e := range adminEndpoints() {
|
|
s.adminRequest("", e.method, e.path, e.body, http.StatusUnauthorized)
|
|
}
|
|
|
|
if held := server.Ledger.Snapshot(); len(held) != 0 {
|
|
t.Errorf("the ledger holds %+v, want no ban", held)
|
|
}
|
|
}
|
|
|
|
// adminEndpoint is a request to an endpoint SWWAF_ADMIN_TOKEN opens.
|
|
type adminEndpoint struct {
|
|
method, path, body string
|
|
}
|
|
|
|
// adminEndpoints returns a request to each endpoint SWWAF_ADMIN_TOKEN
|
|
// opens: listing the bans, banning otherClient for an hour, lifting the
|
|
// bans on otherClient, and asking about otherClient.
|
|
func adminEndpoints() []adminEndpoint {
|
|
return []adminEndpoint{
|
|
{http.MethodGet, servewaf.BansPath, ""},
|
|
{http.MethodPost, servewaf.BansPath, banOtherClient},
|
|
{http.MethodDelete, servewaf.BansPath + "/" + otherClient, ""},
|
|
{http.MethodGet, servewaf.ClientsPath + otherClient, ""},
|
|
}
|
|
}
|
|
|
|
// admin sends a request with method for path, with body, to servewaf's own
|
|
// address, with the admin token, and checks that it is answered with status, its
|
|
// log line's action admin. It returns the answer.
|
|
func (s *sender) admin(method, path, body string, status int) answer {
|
|
s.t.Helper()
|
|
|
|
return s.adminRequest(adminBearer, method, path, body, status)
|
|
}
|
|
|
|
// adminRequest is admin with authorization as the request's Authorization header
|
|
// unless it is "".
|
|
func (s *sender) adminRequest(
|
|
authorization, method, path, body string, status int,
|
|
) answer {
|
|
s.t.Helper()
|
|
|
|
req := newRequest(s.t, method, s.serveWAFAddr, path, strings.NewReader(body))
|
|
if authorization != "" {
|
|
req.Header.Set("Authorization", authorization)
|
|
}
|
|
|
|
got := do(s.t, req)
|
|
wantStatus(s.t, got, status)
|
|
|
|
line := s.out.endedLines(s.t, s.sent+1)[s.sent]
|
|
s.sent++
|
|
wantLine(s.t, line, status, requestlog.ActionAdmin)
|
|
|
|
return got
|
|
}
|
|
|
|
// wantBans checks that a ban endpoint answered with want, and no other
|
|
// ban.
|
|
func wantBans(t *testing.T, got answer, want ...state.BanEntry) {
|
|
t.Helper()
|
|
|
|
var decoded struct {
|
|
Bans []state.BanEntry `json:"bans"`
|
|
}
|
|
|
|
decode(t, got, &decoded)
|
|
|
|
gotJSON, err := json.Marshal(decoded.Bans)
|
|
if err != nil {
|
|
t.Fatalf("encode %+v: %v", decoded.Bans, err)
|
|
}
|
|
|
|
wantJSON, err := json.Marshal(want)
|
|
if err != nil {
|
|
t.Fatalf("encode %+v: %v", want, err)
|
|
}
|
|
|
|
if string(gotJSON) != string(wantJSON) {
|
|
t.Errorf("bans\n%s\nwant\n%s", gotJSON, wantJSON)
|
|
}
|
|
}
|
|
|
|
// decode reads the JSON answer of an endpoint into value.
|
|
func decode(t *testing.T, got answer, value any) {
|
|
t.Helper()
|
|
|
|
err := json.Unmarshal(got.body, value)
|
|
if err != nil {
|
|
t.Fatalf("decode %s: %v", got.body, err)
|
|
}
|
|
}
|