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 : 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 // . 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/: 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: 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 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')) }