check / check (push) Successful in 3m24s
smallwebwaf now copies its state to bans.json, clients.json and lookups.json in SWWAF_STATE_DIR, as "Persistent state" in SPEC.md describes, and reads them back at start, so a restart lifts no ban and gives no client a fresh allowance. Each client gains a history, and a ban's notes count the netblock's requests. bans.json is written SWWAF_STATE_WRITE_DELAY after a ban, and every file every SWWAF_STATE_COUNTER_INTERVAL and at the stop. A ban read back is masked to its netblock and refuses every client in it. A file that does not parse, an unknown version, an entry without a field it needs, or an unwritable directory stops the start. Deviation: no AS number or name, and no ban cause, reason or lifting yet. Model: opus-5-5
104 lines
2.8 KiB
Go
104 lines
2.8 KiB
Go
package proxy_test
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/netip"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
)
|
|
|
|
func TestHistoryKeepsEachRequestOfTheClient(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
geojsURL, _ := startGeoJS(t)
|
|
s, clk, server := startWithClock(t, geojsURL, map[string]string{
|
|
rateLimitPerMinute: "2",
|
|
deniedCountries: "kp",
|
|
})
|
|
start := clk.Now()
|
|
|
|
// Two let through, one over the limit, which bans the client, and one
|
|
// refused under that ban, for which the country is not looked up.
|
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
clk.advance(time.Second)
|
|
s.get(fromDE, http.StatusOK, requestlog.ActionForward)
|
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionRateLimited)
|
|
clk.advance(time.Second)
|
|
s.get(fromDE, http.StatusForbidden, requestlog.ActionBanned)
|
|
|
|
want := ratelimit.History{
|
|
FirstSeen: start,
|
|
LastSeen: start.Add(2 * time.Second),
|
|
Country: "DE",
|
|
LookedUp: start.Add(time.Second),
|
|
Requests: 4,
|
|
Forwarded: 2,
|
|
Refused: 2,
|
|
// The app answers with no body, smallwebwaf with its status text.
|
|
ResponseBytes: 2 * int64(len("Forbidden\n")),
|
|
Responses: ratelimit.Responses{Status2xx: 2, Status4xx: 2},
|
|
Offences: ratelimit.Offences{Limit: 1},
|
|
}
|
|
|
|
got := historyOf(t, server, fromDE)
|
|
if got != want {
|
|
t.Errorf("history\n%+v\nwant\n%+v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestHistoryCountsTheBodiesEachWay(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.Copy(io.Discard, r.Body)
|
|
_, _ = io.WriteString(w, "hello")
|
|
})
|
|
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
|
|
|
|
got := do(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("abc")))
|
|
wantStatus(t, got, http.StatusOK)
|
|
out.requestLine(t)
|
|
|
|
history := historyOf(t, server, localhost)
|
|
if history.RequestBytes != 3 || history.ResponseBytes != 5 {
|
|
t.Errorf("history counts %d bytes in and %d out, want 3 and 5",
|
|
history.RequestBytes, history.ResponseBytes)
|
|
}
|
|
}
|
|
|
|
func TestHealthEndpointIsNotInTheHistory(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {})
|
|
addr, out, server := startProxyWithClock(t, app.URL, "", time.Now, nil)
|
|
|
|
wantStatus(t, get(t, addr, proxy.HealthPath), http.StatusOK)
|
|
out.requestLine(t)
|
|
|
|
if clients := server.Limiter.Snapshot(); len(clients) != 0 {
|
|
t.Errorf("the table holds %+v, want no client", clients)
|
|
}
|
|
}
|
|
|
|
// historyOf returns the history of the client at addr.
|
|
func historyOf(t *testing.T, server *proxy.Server, addr string) ratelimit.History {
|
|
t.Helper()
|
|
|
|
client := netip.MustParsePrefix(addr + "/32")
|
|
for _, c := range server.Limiter.Snapshot() {
|
|
if c.Client == client {
|
|
return c.History
|
|
}
|
|
}
|
|
|
|
t.Fatalf("%s is not in the table", client)
|
|
|
|
return ratelimit.History{}
|
|
}
|