check / check (push) Waiting to run
The IPv6 group that is one client, the size of the table of clients and the level of the process's own lines become settings. clientGroup reads the group length from them, so limits, bans, history, lookups, AbuseIPDB scores and per-client anomaly counters all follow it; ratelimit.New takes the table size; the process logger takes the level once the settings are read, and request lines, written apart from it, are never held back. Judgement call: SWWAF_IPV6_GROUP_PREFIX accepts 32 to 128, the issue's example range. 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(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'))
|
|
}
|