Admin endpoints for bans and clients on the single listener (closes #27)
check / check (push) Successful in 4m29s
check / check (push) Successful in 4m29s
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 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:
+246
-7
@@ -2,25 +2,52 @@ package proxy
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/bans"
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
"sneak.berlin/go/smallwebwaf/internal/state"
|
||||
)
|
||||
|
||||
// banBodyMaxBytes is the most of the body of a request to add a ban that
|
||||
// is read; its three fields need far less.
|
||||
const banBodyMaxBytes = 4 << 10
|
||||
|
||||
// permanent is how the log line and the ban endpoint name a ban that
|
||||
// never ends.
|
||||
const permanent = "permanent"
|
||||
|
||||
var (
|
||||
errNotNetblock = errors.New(
|
||||
"is not an address or a netblock, such as 203.0.113.9 or 203.0.113.0/24")
|
||||
errNotDuration = errors.New(
|
||||
"is not a duration above zero, such as 1h or 7d, or permanent")
|
||||
errNotAddress = errors.New("is not an address, such as 203.0.113.9")
|
||||
)
|
||||
|
||||
// answerAdmin answers a request for smallwebwaf itself, under
|
||||
// /_smallwebwaf/, once it has passed the checks: GET MetricsPath with
|
||||
// SWWAF_METRICS_TOKEN gets the metrics, and without it is refused with
|
||||
// 401. Any other request gets 404, as the metrics do while
|
||||
// SWWAF_METRICS_TOKEN is unset.
|
||||
// /_smallwebwaf/, once it has passed the checks. Each endpoint needs a
|
||||
// token, sent as Authorization: Bearer <token>: the metrics
|
||||
// SWWAF_METRICS_TOKEN, the others SWWAF_ADMIN_TOKEN. A request without
|
||||
// it is refused with 401. An endpoint whose token is unset answers 404,
|
||||
// as any other request under /_smallwebwaf/ does.
|
||||
func (rq *request) answerAdmin() {
|
||||
rq.line.Action = requestlog.ActionAdmin
|
||||
rq.startClientResponseTimeout()
|
||||
|
||||
token := rq.h.config.MetricsToken
|
||||
token, answer := rq.endpoint()
|
||||
|
||||
switch {
|
||||
case token == "" || rq.in.Method != http.MethodGet || rq.in.URL.Path != MetricsPath:
|
||||
case token == "":
|
||||
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||
case !hasToken(rq.in, token):
|
||||
rq.out.Header().Set("WWW-Authenticate", "Bearer")
|
||||
@@ -29,7 +56,29 @@ func (rq *request) answerAdmin() {
|
||||
action: requestlog.ActionAdmin,
|
||||
})
|
||||
default:
|
||||
rq.h.metrics.ServeHTTP(rq.out, rq.in)
|
||||
answer()
|
||||
}
|
||||
}
|
||||
|
||||
// endpoint returns the token the request's endpoint needs, and what
|
||||
// answers the request there; "" when there is no such endpoint.
|
||||
func (rq *request) endpoint() (string, func()) {
|
||||
cfg := rq.h.config
|
||||
method, path := rq.in.Method, rq.in.URL.Path
|
||||
|
||||
switch {
|
||||
case method == http.MethodGet && path == MetricsPath:
|
||||
return cfg.MetricsToken, func() { rq.h.metrics.ServeHTTP(rq.out, rq.in) }
|
||||
case method == http.MethodGet && path == BansPath:
|
||||
return cfg.AdminToken, rq.listBans
|
||||
case method == http.MethodPost && path == BansPath:
|
||||
return cfg.AdminToken, rq.addBan
|
||||
case method == http.MethodDelete && strings.HasPrefix(path, BansPath+"/"):
|
||||
return cfg.AdminToken, rq.liftBans
|
||||
case method == http.MethodGet && strings.HasPrefix(path, ClientsPath):
|
||||
return cfg.AdminToken, rq.showClient
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,3 +90,193 @@ func hasToken(r *http.Request, token string) bool {
|
||||
return strings.EqualFold(scheme, "Bearer") &&
|
||||
subtle.ConstantTimeCompare([]byte(sent), []byte(token)) == 1
|
||||
}
|
||||
|
||||
// listBans answers GET BansPath with every ban held.
|
||||
func (rq *request) listBans() {
|
||||
rq.answerBans(rq.h.ledger.Snapshot())
|
||||
}
|
||||
|
||||
// banToAdd is the body of POST BansPath.
|
||||
type banToAdd struct {
|
||||
// Netblock is a netblock, or a client's address, which stands for the
|
||||
// netblock a ban on that client covers.
|
||||
Netblock string `json:"netblock"`
|
||||
// Duration is how long the ban lasts, as a setting gives a duration,
|
||||
// or permanent.
|
||||
Duration string `json:"duration"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// addBan answers POST BansPath: it bans the netblock the body names, as
|
||||
// an admin, from now for the duration the body gives, with its reason,
|
||||
// and answers with that ban.
|
||||
func (rq *request) addBan() {
|
||||
toAdd, err := rq.readBanToAdd()
|
||||
if refused := rq.refused.Load(); refused != nil {
|
||||
rq.answer(*refused) // the body is over SWWAF_REQUEST_MAX_BYTES
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
netblock netip.Prefix
|
||||
expires time.Time
|
||||
now = rq.h.now()
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
netblock, err = rq.h.banNetblock(toAdd.Netblock)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
expires, err = expiry(toAdd.Duration, now)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
http.Error(rq.out, err.Error(), http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ban := rq.h.ledger.BanForAdmin(netblock, now, expires, toAdd.Reason)
|
||||
rq.answerBans([]bans.Ban{ban})
|
||||
}
|
||||
|
||||
// readBanToAdd reads the body of POST BansPath, at most banBodyMaxBytes
|
||||
// of it.
|
||||
func (rq *request) readBanToAdd() (banToAdd, error) {
|
||||
var body io.ReadCloser = http.NoBody
|
||||
if rq.body != nil {
|
||||
body = rq.body
|
||||
}
|
||||
|
||||
var toAdd banToAdd
|
||||
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(nil, body, banBodyMaxBytes))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
err := decoder.Decode(&toAdd)
|
||||
if err != nil {
|
||||
return banToAdd{}, fmt.Errorf(
|
||||
"the body is not a JSON object of netblock, duration and reason: %w", err)
|
||||
}
|
||||
|
||||
return toAdd, nil
|
||||
}
|
||||
|
||||
// banNetblock reads value, a netblock such as 203.0.113.0/24, or a
|
||||
// client's address, which stands for the netblock a ban on that client
|
||||
// covers.
|
||||
func (h *handler) banNetblock(value string) (netip.Prefix, error) {
|
||||
netblock, err := netip.ParsePrefix(value)
|
||||
if err == nil {
|
||||
return netblock, nil
|
||||
}
|
||||
|
||||
addr, err := netip.ParseAddr(value)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("netblock %q %w", value, errNotNetblock)
|
||||
}
|
||||
|
||||
return h.netblock(addr), nil
|
||||
}
|
||||
|
||||
// expiry returns when a ban made at now for duration ends: duration
|
||||
// later, for a duration as a setting gives one, or zero for permanent.
|
||||
func expiry(duration string, now time.Time) (time.Time, error) {
|
||||
if duration == permanent {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
|
||||
length, err := config.ParseDurationNotOff(duration)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("duration %q %w", duration, errNotDuration)
|
||||
}
|
||||
|
||||
return now.Add(length), nil
|
||||
}
|
||||
|
||||
// liftBans answers DELETE BansPath/<client>: it lifts every ban active on
|
||||
// a netblock the client's address is in, and answers with those bans, or
|
||||
// with 404 when none is active.
|
||||
func (rq *request) liftBans() {
|
||||
client, err := pathAddress(rq.in.URL.Path, BansPath+"/")
|
||||
if err != nil {
|
||||
http.Error(rq.out, err.Error(), http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
lifted := rq.h.ledger.Lift(client, rq.h.now())
|
||||
if len(lifted) == 0 {
|
||||
http.Error(rq.out, "no ban is active on "+client.String(), http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rq.answerBans(lifted)
|
||||
}
|
||||
|
||||
// clientAnswer is the answer to GET ClientsPath<ip>: the client the
|
||||
// address is, as clients.json holds it, or null when the table of
|
||||
// clients does not hold it, and the bans on each netblock the address is
|
||||
// in, as bans.json lists them.
|
||||
type clientAnswer struct {
|
||||
Client *ratelimit.Client `json:"client"`
|
||||
Bans []state.BanEntry `json:"bans"`
|
||||
}
|
||||
|
||||
// showClient answers GET ClientsPath<ip> with what smallwebwaf knows of
|
||||
// the client: its counters, its history, which holds its country as last
|
||||
// looked up and its offences, and its bans with their notes.
|
||||
func (rq *request) showClient() {
|
||||
addr, err := pathAddress(rq.in.URL.Path, ClientsPath)
|
||||
if err != nil {
|
||||
http.Error(rq.out, err.Error(), http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
answer := clientAnswer{Bans: state.BanEntries(rq.h.ledger.Covering(addr))}
|
||||
|
||||
client, seen := rq.h.limiter.Client(clientGroup(addr))
|
||||
if seen {
|
||||
answer.Client = &client
|
||||
}
|
||||
|
||||
rq.answerJSON(answer)
|
||||
}
|
||||
|
||||
// pathAddress reads the client's address that follows prefix in path.
|
||||
func pathAddress(path, prefix string) (netip.Addr, error) {
|
||||
value := strings.TrimPrefix(path, prefix)
|
||||
|
||||
addr, err := netip.ParseAddr(value)
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("%q %w", value, errNotAddress)
|
||||
}
|
||||
|
||||
return addr.Unmap(), nil
|
||||
}
|
||||
|
||||
// answerBans answers with held under bans, as bans.json lists them.
|
||||
func (rq *request) answerBans(held []bans.Ban) {
|
||||
rq.answerJSON(struct {
|
||||
Bans []state.BanEntry `json:"bans"`
|
||||
}{state.BanEntries(held)})
|
||||
}
|
||||
|
||||
// answerJSON answers with value as indented JSON.
|
||||
func (rq *request) answerJSON(value any) {
|
||||
body, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
rq.h.processLog.Error("encoding an answer failed", "error", err.Error())
|
||||
http.Error(rq.out, http.StatusText(http.StatusInternalServerError),
|
||||
http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rq.out.Header().Set("Content-Type", "application/json")
|
||||
_, _ = rq.out.Write(append(body, '\n'))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user