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. 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
307 lines
9.1 KiB
Go
307 lines
9.1 KiB
Go
package servewaf
|
|
|
|
import (
|
|
"bytes"
|
|
"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 (
|
|
errNotBanToAdd = errors.New(
|
|
"the body is not a JSON object of netblock, duration and reason")
|
|
errNotNetblock = errors.New(
|
|
"is not an address or a netblock, such as 203.0.113.9 or 203.0.113.0/24")
|
|
errMappedNetblock = errors.New(
|
|
"is IPv4-mapped: give the IPv4 netblock, such as 203.0.113.0/24")
|
|
errZone = errors.New("has a zone, which a netblock cannot have")
|
|
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")
|
|
)
|
|
|
|
// answerHealth answers a request for the health endpoint.
|
|
func (rq *request) answerHealth() {
|
|
rq.line.Action = requestlog.ActionAdmin
|
|
// Set here rather than left to Go's server, which would set it only
|
|
// after the log line has taken the response's headers.
|
|
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
_, _ = io.WriteString(rq.out, "ok\n")
|
|
}
|
|
|
|
// answerAdmin answers a request for one of servewaf's own endpoints. Each
|
|
// 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
|
|
// path does.
|
|
func (rq *request) answerAdmin() {
|
|
rq.line.Action = requestlog.ActionAdmin
|
|
|
|
token, answer := rq.endpoint()
|
|
|
|
switch {
|
|
case token == "":
|
|
http.Error(rq.out, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
|
case !hasToken(rq.in, token):
|
|
rq.out.Header().Set("WWW-Authenticate", "Bearer")
|
|
http.Error(rq.out, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
|
default:
|
|
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
|
|
}
|
|
}
|
|
|
|
// hasToken reports whether r carries token, as Authorization: Bearer
|
|
// <token>.
|
|
func hasToken(r *http.Request, token string) bool {
|
|
scheme, sent, _ := strings.Cut(r.Header.Get("Authorization"), " ")
|
|
|
|
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()
|
|
|
|
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: a JSON object with
|
|
// nothing but whitespace after it, in at most banBodyMaxBytes.
|
|
func (rq *request) readBanToAdd() (banToAdd, error) {
|
|
data, err := io.ReadAll(http.MaxBytesReader(nil, rq.in.Body, banBodyMaxBytes))
|
|
rq.requestBytes = int64(len(data))
|
|
|
|
if err != nil {
|
|
return banToAdd{}, fmt.Errorf("%w: %w", errNotBanToAdd, err)
|
|
}
|
|
|
|
var toAdd banToAdd
|
|
|
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
decoder.DisallowUnknownFields()
|
|
|
|
err = decoder.Decode(&toAdd)
|
|
if err != nil {
|
|
return banToAdd{}, fmt.Errorf("%w: %w", errNotBanToAdd, err)
|
|
}
|
|
|
|
// Token returns io.EOF only when nothing but whitespace is left.
|
|
_, err = decoder.Token()
|
|
if !errors.Is(err, io.EOF) {
|
|
return banToAdd{}, fmt.Errorf("%w: more follows the object", errNotBanToAdd)
|
|
}
|
|
|
|
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. An IPv4-mapped netblock, such as ::ffff:203.0.113.0/120, is
|
|
// refused, since a client's address is looked up as IPv4 and a ban on it
|
|
// would refuse nothing, and so is a value with a zone.
|
|
func (h *handler) banNetblock(value string) (netip.Prefix, error) {
|
|
netblock, err := netip.ParsePrefix(value)
|
|
if err == nil {
|
|
if netblock.Addr().Is4In6() {
|
|
return netip.Prefix{}, fmt.Errorf("netblock %q %w", value, errMappedNetblock)
|
|
}
|
|
|
|
return netblock, nil
|
|
}
|
|
|
|
// ParsePrefix refuses a zone, but ParseAddr reads the /48 of
|
|
// 2001:db8::1%x/48 as part of the zone.
|
|
addr, err := netip.ParseAddr(value)
|
|
if err != nil {
|
|
return netip.Prefix{}, fmt.Errorf("netblock %q %w", value, errNotNetblock)
|
|
}
|
|
|
|
if addr.Zone() != "" {
|
|
return netip.Prefix{}, fmt.Errorf("netblock %q %w", value, errZone)
|
|
}
|
|
|
|
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 servewaf 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(rq.h.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'))
|
|
}
|