Admin endpoints for bans and clients on the single listener (closes #27)
check / check (push) Successful in 4m1s
check / check (push) Successful in 4m1s
SWWAF_ADMIN_TOKEN, or its _FILE form, opens GET and POST /_smallwebwaf/bans, DELETE /_smallwebwaf/bans/<client> and GET /_smallwebwaf/clients/<ip>. Unset, they answer 404; a missing or wrong token gets 401, in observe mode too. They go through every check, as the metrics do. POST takes a netblock, not IPv4-mapped and without a zone, or a client's address, a duration or permanent, and a reason, and makes an admin ban even while another lasts. DELETE lifts every active ban covering the address, kept and marked lifted. Bans come back as bans.json entries; a client as clients.json holds it, with its bans. Judgement call: answers leave out bans.json's version field. Judgement call: DELETE takes an address, not a netblock. Rule suppressed: gosec G304 on a test reading bans.json. Model: opus-5-5
This commit is contained in:
@@ -37,6 +37,9 @@ const (
|
||||
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
|
||||
rateLimitPerDay = "SWWAF_RATE_LIMIT_PER_DAY"
|
||||
rulesDir = "SWWAF_RULES_DIR"
|
||||
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||
// adminSecret is the SWWAF_ADMIN_TOKEN the tests set.
|
||||
adminSecret = "fedcba9876543210fedcba9876543210"
|
||||
// greeting is what the tests' app answers.
|
||||
greeting = "hello from the app"
|
||||
)
|
||||
@@ -127,25 +130,31 @@ func TestInvalidSettingStopsTheStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortMetricsTokenStopsTheStartUnshown(t *testing.T) {
|
||||
func TestShortTokenStopsTheStartUnshown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const token = "a-token-of-31-characters-at-all" //nolint:gosec // too short to use
|
||||
|
||||
out := &output{}
|
||||
for _, name := range []string{adminToken, "SWWAF_METRICS_TOKEN"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
status := run(t.Context(), map[string]string{"SWWAF_METRICS_TOKEN": token}, out)
|
||||
if status != 1 {
|
||||
t.Errorf("exit status %d, want 1", status)
|
||||
}
|
||||
out := &output{}
|
||||
|
||||
line := out.line(t, "msg", "invalid setting")
|
||||
if line["error"] != "SWWAF_METRICS_TOKEN: is shorter than 32 characters" {
|
||||
t.Errorf("start refused with %v", line)
|
||||
}
|
||||
status := run(t.Context(), map[string]string{name: token}, out)
|
||||
if status != 1 {
|
||||
t.Errorf("exit status %d, want 1", status)
|
||||
}
|
||||
|
||||
if strings.Contains(out.text(), token) {
|
||||
t.Errorf("the output shows the token:\n%s", out.text())
|
||||
line := out.line(t, "msg", "invalid setting")
|
||||
if line["error"] != name+": is shorter than 32 characters" {
|
||||
t.Errorf("start refused with %v", line)
|
||||
}
|
||||
|
||||
if strings.Contains(out.text(), token) {
|
||||
t.Errorf("the output shows the token:\n%s", out.text())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +338,51 @@ func TestBanAddedAndLiftedByEditingBansJSON(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestBanAddedAndLiftedThroughTheEndpointsKeptInBansJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
env := map[string]string{
|
||||
listenAddr: localhost + ":0",
|
||||
upstreamURL: startApp(t),
|
||||
stateDir: dir,
|
||||
rulesDir: t.TempDir(),
|
||||
trustedProxies: localhost + "/32",
|
||||
adminToken: adminSecret,
|
||||
// Neither comes due in the test: the files are written as
|
||||
// smallwebwaf stops.
|
||||
stateWriteDelay: "1h",
|
||||
stateCounterInterval: "1h",
|
||||
}
|
||||
|
||||
runUntilStopped(t, env, func(url string) {
|
||||
askAsAdmin(t, http.MethodPost, url+"_smallwebwaf/bans",
|
||||
`{"netblock": "203.0.113.0/24", "duration": "permanent", `+
|
||||
`"reason": "probes for logins"}`)
|
||||
wantStatus(t, url, "203.0.113.9", http.StatusForbidden)
|
||||
})
|
||||
|
||||
ban := onlyBan(t, dir)
|
||||
if ban["netblock"] != "203.0.113.0/24" || ban["cause"] != "admin" ||
|
||||
ban["reason"] != "probes for logins" || ban["expires"] != nil ||
|
||||
ban["lifted"] != nil {
|
||||
t.Errorf("bans.json holds %v, want the admin's permanent ban", ban)
|
||||
}
|
||||
|
||||
// After a restart the ban still refuses; once lifted, it refuses no
|
||||
// more, and bans.json keeps it, marked lifted.
|
||||
runUntilStopped(t, env, func(url string) {
|
||||
wantStatus(t, url, "203.0.113.9", http.StatusForbidden)
|
||||
askAsAdmin(t, http.MethodDelete, url+"_smallwebwaf/bans/203.0.113.9", "")
|
||||
wantStatus(t, url, "203.0.113.9", http.StatusOK)
|
||||
})
|
||||
|
||||
ban = onlyBan(t, dir)
|
||||
if ban["netblock"] != "203.0.113.0/24" || ban["lifted"] == nil {
|
||||
t.Errorf("bans.json holds %v, want the admin's ban, lifted", ban)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleFileAddedWhileRunningTakesEffect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -752,6 +806,57 @@ func metricsText(t *testing.T, url, token string) string {
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// askAsAdmin sends a request with method to url, with body and
|
||||
// adminSecret, and checks that it is answered 200.
|
||||
func askAsAdmin(t *testing.T, method, url, body string) {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), method, url,
|
||||
strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+adminSecret)
|
||||
|
||||
transport := &http.Transport{}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
res, err := (&http.Client{Transport: transport}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
|
||||
_ = res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s %s answered %d", method, url, res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// onlyBan returns the one ban bans.json in dir holds.
|
||||
func onlyBan(t *testing.T, dir string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(dir, "bans.json")
|
||||
|
||||
data, err := os.ReadFile(path) //nolint:gosec // a file in the test's directory
|
||||
if err != nil {
|
||||
t.Fatalf("read bans.json: %v", err)
|
||||
}
|
||||
|
||||
var file struct {
|
||||
Bans []map[string]any `json:"bans"`
|
||||
}
|
||||
|
||||
err = json.Unmarshal(data, &file)
|
||||
if err != nil || len(file.Bans) != 1 {
|
||||
t.Fatalf("bans.json holds\n%s\nwant one ban (%v)", data, err)
|
||||
}
|
||||
|
||||
return file.Bans[0]
|
||||
}
|
||||
|
||||
// wantRefused checks that a request to url is refused with 403, the
|
||||
// default SWWAF_BAN_RESPONSE.
|
||||
func wantRefused(t *testing.T, url string) {
|
||||
|
||||
Reference in New Issue
Block a user