Files
gamertan 3a4b6db9b8
verify / verify (push) Successful in 2m55s
feat: publish Gamertan Web Foundations preview source
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>
2026-08-16 15:05:40 -04:00

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
}