check / check (push) Waiting to run
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
326 lines
9.4 KiB
Go
326 lines
9.4 KiB
Go
package proxy
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/netip"
|
|
"os"
|
|
"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")
|
|
)
|
|
|
|
// answerAdmin answers a request for smallwebwaf itself, under
|
|
// /_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, 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")
|
|
rq.answer(refusal{
|
|
status: http.StatusUnauthorized,
|
|
action: requestlog.ActionAdmin,
|
|
})
|
|
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() {
|
|
// The body must arrive within SWWAF_CLIENT_REQUEST_TIMEOUT, as any
|
|
// other request's must.
|
|
rq.stopReadingBody(rq.clientRequestDeadline())
|
|
|
|
toAdd, err := rq.readBanToAdd()
|
|
if refused := rq.refused.Load(); refused != nil {
|
|
rq.answer(*refused) // the body is over SWWAF_REQUEST_MAX_BYTES
|
|
|
|
return
|
|
}
|
|
|
|
if errors.Is(err, os.ErrDeadlineExceeded) {
|
|
rq.answer(refusal{
|
|
status: http.StatusRequestTimeout,
|
|
action: requestlog.ActionTimedOut,
|
|
limit: "SWWAF_CLIENT_REQUEST_TIMEOUT",
|
|
})
|
|
|
|
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: a JSON object with
|
|
// nothing but whitespace after it, in at most banBodyMaxBytes.
|
|
func (rq *request) readBanToAdd() (banToAdd, error) {
|
|
var body io.ReadCloser = http.NoBody
|
|
if rq.body != nil {
|
|
body = rq.body
|
|
}
|
|
|
|
data, err := io.ReadAll(http.MaxBytesReader(nil, body, banBodyMaxBytes))
|
|
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 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'))
|
|
}
|