verify / verify (push) Successful in 2m55s
Export the reviewed application-neutral package set through the exact public allowlist. Development history and private application evidence remain outside this canonical source root. Developed with material AI assistance under maintainer review. Signed-off-by: Cole Speelman <crspeelman@gmail.com>
246 lines
6.8 KiB
Go
246 lines
6.8 KiB
Go
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
// Package abuse provides application-classified request enforcement. It does
|
|
// not guess which routes or probes are malicious for an application.
|
|
package abuse
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"gamertan.com/web/requestmeta"
|
|
)
|
|
|
|
type Severity uint8
|
|
|
|
const (
|
|
Ignore Severity = iota
|
|
Strike
|
|
ImmediateBlock
|
|
PermanentBlock
|
|
)
|
|
|
|
var ErrCapacity = errors.New("abuse: store capacity exhausted")
|
|
|
|
type Signal struct {
|
|
Severity Severity
|
|
Reason string
|
|
}
|
|
|
|
type Decision struct {
|
|
BlockedUntil time.Time
|
|
Permanent bool
|
|
Reason string
|
|
Strikes int
|
|
}
|
|
|
|
func (decision Decision) Blocked(now time.Time) bool {
|
|
return decision.Permanent || decision.BlockedUntil.After(now)
|
|
}
|
|
|
|
type Store interface {
|
|
Lookup(context.Context, string, time.Time) (Decision, error)
|
|
Record(context.Context, string, Signal, time.Time, Policy) (Decision, error)
|
|
Pardon(context.Context, string, time.Time) error
|
|
Cleanup(context.Context, time.Time, time.Duration, int) error
|
|
}
|
|
|
|
type Policy struct {
|
|
Threshold int
|
|
Window time.Duration
|
|
BlockDuration time.Duration
|
|
Retention time.Duration
|
|
MaxClients int
|
|
}
|
|
|
|
func (policy Policy) Validate() error {
|
|
if policy.Threshold < 1 || policy.Threshold > 1000 || policy.Window < time.Second || policy.Window > 24*time.Hour || policy.BlockDuration < time.Second || policy.BlockDuration > 365*24*time.Hour || policy.Retention < policy.Window || policy.Retention > 10*365*24*time.Hour || policy.MaxClients < 1 || policy.MaxClients > 1000000 {
|
|
return errors.New("abuse: invalid policy")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type Classifier func(*http.Request) Signal
|
|
|
|
type Engine struct {
|
|
Store Store
|
|
Policy Policy
|
|
Classify Classifier
|
|
Now func() time.Time
|
|
OnError func(error)
|
|
}
|
|
|
|
func (engine Engine) Middleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
|
if engine.Store == nil || engine.Policy.Validate() != nil {
|
|
http.Error(response, "security policy unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
if engine.Now != nil {
|
|
now = engine.Now().UTC()
|
|
}
|
|
metadata, ok := requestmeta.FromContext(request.Context())
|
|
if !ok || !metadata.ClientIP.IsValid() {
|
|
http.Error(response, "request identity unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
key := metadata.ClientIP.String()
|
|
decision, err := engine.Store.Lookup(request.Context(), key, now)
|
|
if err != nil {
|
|
engine.fail(response, err)
|
|
return
|
|
}
|
|
if decision.Blocked(now) {
|
|
block(response, decision, now)
|
|
return
|
|
}
|
|
signal := Signal{}
|
|
if engine.Classify != nil {
|
|
signal = engine.Classify(request)
|
|
}
|
|
if signal.Severity != Ignore {
|
|
decision, err = engine.Store.Record(request.Context(), key, signal, now, engine.Policy)
|
|
if err != nil {
|
|
engine.fail(response, err)
|
|
return
|
|
}
|
|
if decision.Blocked(now) {
|
|
block(response, decision, now)
|
|
return
|
|
}
|
|
}
|
|
next.ServeHTTP(response, request)
|
|
})
|
|
}
|
|
|
|
func (engine Engine) fail(response http.ResponseWriter, err error) {
|
|
if engine.OnError != nil {
|
|
engine.OnError(errors.New("abuse: persistence unavailable"))
|
|
}
|
|
response.Header().Set("Cache-Control", "no-store")
|
|
http.Error(response, "security policy unavailable", http.StatusServiceUnavailable)
|
|
}
|
|
|
|
func block(response http.ResponseWriter, decision Decision, now time.Time) {
|
|
response.Header().Set("Cache-Control", "no-store")
|
|
if decision.Permanent {
|
|
http.Error(response, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
seconds := int64(decision.BlockedUntil.Sub(now).Seconds())
|
|
if seconds < 1 {
|
|
seconds = 1
|
|
}
|
|
response.Header().Set("Retry-After", strconv.FormatInt(seconds, 10))
|
|
http.Error(response, "too many requests", http.StatusTooManyRequests)
|
|
}
|
|
|
|
// MemoryStore is a bounded single-process reference implementation and test
|
|
// adapter. Durable applications should implement Store with private storage.
|
|
type MemoryStore struct {
|
|
mu sync.Mutex
|
|
clients map[string]client
|
|
}
|
|
type client struct {
|
|
first, last time.Time
|
|
strikes int
|
|
decision Decision
|
|
}
|
|
|
|
func NewMemoryStore() *MemoryStore { return &MemoryStore{clients: make(map[string]client)} }
|
|
|
|
func (store *MemoryStore) Lookup(_ context.Context, key string, now time.Time) (Decision, error) {
|
|
if key == "" || len(key) > 128 || now.IsZero() {
|
|
return Decision{}, errors.New("abuse: invalid lookup")
|
|
}
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
entry, exists := store.clients[key]
|
|
if !exists {
|
|
return Decision{}, nil
|
|
}
|
|
if !entry.decision.Permanent && !entry.decision.BlockedUntil.After(now) {
|
|
entry.decision.BlockedUntil = time.Time{}
|
|
store.clients[key] = entry
|
|
}
|
|
return entry.decision, nil
|
|
}
|
|
|
|
func (store *MemoryStore) Record(_ context.Context, key string, signal Signal, now time.Time, policy Policy) (Decision, error) {
|
|
if key == "" || len(key) > 128 || signal.Severity < Strike || signal.Severity > PermanentBlock || signal.Reason == "" || len(signal.Reason) > 256 || now.IsZero() || policy.Validate() != nil {
|
|
return Decision{}, errors.New("abuse: invalid record")
|
|
}
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
entry, exists := store.clients[key]
|
|
if !exists && len(store.clients) >= policy.MaxClients && !store.evictOldest() {
|
|
return Decision{}, ErrCapacity
|
|
}
|
|
if entry.first.IsZero() || now.Sub(entry.first) > policy.Window {
|
|
entry.first, entry.strikes = now, 0
|
|
}
|
|
entry.last = now
|
|
entry.strikes++
|
|
entry.decision.Strikes, entry.decision.Reason = entry.strikes, signal.Reason
|
|
if signal.Severity == PermanentBlock {
|
|
entry.decision.Permanent = true
|
|
}
|
|
if (signal.Severity == ImmediateBlock || entry.strikes >= policy.Threshold) && !entry.decision.Permanent {
|
|
entry.decision.BlockedUntil = now.Add(policy.BlockDuration)
|
|
}
|
|
store.clients[key] = entry
|
|
return entry.decision, nil
|
|
}
|
|
|
|
func (store *MemoryStore) Pardon(_ context.Context, key string, _ time.Time) error {
|
|
if key == "" || len(key) > 128 {
|
|
return errors.New("abuse: invalid pardon")
|
|
}
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
delete(store.clients, key)
|
|
return nil
|
|
}
|
|
|
|
func (store *MemoryStore) Cleanup(_ context.Context, now time.Time, retention time.Duration, max int) error {
|
|
if now.IsZero() || retention <= 0 || max < 1 {
|
|
return errors.New("abuse: invalid cleanup")
|
|
}
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
for key, entry := range store.clients {
|
|
if !entry.decision.Permanent && now.Sub(entry.last) > retention {
|
|
delete(store.clients, key)
|
|
}
|
|
}
|
|
for len(store.clients) > max {
|
|
if !store.evictOldest() {
|
|
return ErrCapacity
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (store *MemoryStore) evictOldest() bool {
|
|
var selected string
|
|
var oldest time.Time
|
|
for key, entry := range store.clients {
|
|
if entry.decision.Permanent {
|
|
continue
|
|
}
|
|
if selected == "" || entry.last.Before(oldest) {
|
|
selected, oldest = key, entry.last
|
|
}
|
|
}
|
|
if selected != "" {
|
|
delete(store.clients, selected)
|
|
return true
|
|
}
|
|
return false
|
|
}
|