mcp connection
This commit is contained in:
209
go-api/internal/ratelimit/ratelimit.go
Normal file
209
go-api/internal/ratelimit/ratelimit.go
Normal file
@@ -0,0 +1,209 @@
|
||||
// Package ratelimit is a fixed-window request limiter shared across API
|
||||
// instances.
|
||||
//
|
||||
// It exists because the limiter this service already had — httpserver's
|
||||
// attemptLimiter — is an in-process map, and an in-process limiter behind N
|
||||
// replicas enforces N times the configured limit. That is fine for the thing it
|
||||
// guards (failed logins, where the real defence is the password hash's cost)
|
||||
// and not fine for an endpoint that writes a database row for any caller who
|
||||
// can reach it.
|
||||
//
|
||||
// The existing limiter is deliberately left alone. Replacing it is not this
|
||||
// phase's job, it would change login behaviour, and the two have different
|
||||
// shapes: attemptLimiter counts FAILURES and resets on success, which is the
|
||||
// right model for a password and the wrong one for a request budget.
|
||||
//
|
||||
// WHAT THIS GUARANTEES
|
||||
//
|
||||
// - Correct under concurrency, including across instances: the count comes
|
||||
// back from the same statement that increments it, so two callers cannot
|
||||
// both read "9" and both proceed.
|
||||
// - Bounded memory: the state is a table, swept by the cleanup job.
|
||||
// - No credential ever becomes a key: callers pass an already-hashed subject,
|
||||
// and Key refuses to build a bucket from anything that looks raw.
|
||||
//
|
||||
// # WHAT IT DOES NOT GUARANTEE
|
||||
//
|
||||
// Exactness at a window boundary. A fixed window admits up to 2× the limit
|
||||
// across the seam — ten requests at 11:59:59 and ten more at 12:00:01. A
|
||||
// sliding window would fix that and costs a row per request. For abuse
|
||||
// prevention the burst is acceptable and the trade is deliberate.
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/repo"
|
||||
)
|
||||
|
||||
// Limiter counts requests per bucket per window.
|
||||
type Limiter struct {
|
||||
db repo.Querier
|
||||
now func() time.Time
|
||||
|
||||
// failOpen decides what happens when the DATABASE fails, which is the one
|
||||
// judgement call in this package.
|
||||
//
|
||||
// Default false — fail CLOSED. A limiter that cannot count is a limiter
|
||||
// that is not limiting, and these endpoints are the ones worth protecting
|
||||
// most when things are already going wrong. The alternative, failing open,
|
||||
// turns a database blip into an unmetered window on an endpoint that
|
||||
// writes rows for anonymous callers.
|
||||
//
|
||||
// Configurable because that is not the right answer everywhere: a
|
||||
// deployment that would rather serve MCP degraded than refuse it can say
|
||||
// so deliberately, in one place, rather than by a comment somebody has to
|
||||
// remember.
|
||||
failOpen bool
|
||||
}
|
||||
|
||||
// New builds a limiter over the shared pool.
|
||||
func New(db repo.Querier) *Limiter {
|
||||
return &Limiter{db: db, now: time.Now}
|
||||
}
|
||||
|
||||
// WithClock replaces the clock, so window rollover can be tested without
|
||||
// waiting for one.
|
||||
func (l *Limiter) WithClock(now func() time.Time) *Limiter {
|
||||
l.now = now
|
||||
return l
|
||||
}
|
||||
|
||||
// WithFailOpen makes a database failure permit the request rather than refuse
|
||||
// it. See the field comment: the default is to refuse.
|
||||
func (l *Limiter) WithFailOpen(open bool) *Limiter {
|
||||
l.failOpen = open
|
||||
return l
|
||||
}
|
||||
|
||||
// Rule is one limit: how many requests, over how long.
|
||||
type Rule struct {
|
||||
// Name is the scope, and it becomes the bucket's prefix. Keep it stable —
|
||||
// renaming a scope resets everyone's counter.
|
||||
Name string
|
||||
|
||||
// Limit is the number of requests permitted per window.
|
||||
Limit int
|
||||
|
||||
// Window is the fixed window's length.
|
||||
Window time.Duration
|
||||
}
|
||||
|
||||
// Decision is the answer for one request.
|
||||
type Decision struct {
|
||||
// Allowed is whether the caller may proceed.
|
||||
Allowed bool
|
||||
|
||||
// Remaining is how many requests are left in this window, never negative.
|
||||
Remaining int
|
||||
|
||||
// RetryAfter is how long until the window rolls over. Rendered into the
|
||||
// Retry-After header on a 429, so a well-behaved client waits exactly long
|
||||
// enough rather than guessing.
|
||||
RetryAfter time.Duration
|
||||
|
||||
// Limit and Window echo the rule, for the response headers.
|
||||
Limit int
|
||||
Window time.Duration
|
||||
}
|
||||
|
||||
// Subject hashes a bucket subject.
|
||||
//
|
||||
// EVERY caller must pass identifying material through this. A bucket key built
|
||||
// from a raw token would write that token to a table, to any log line naming
|
||||
// the bucket, and to every slow-query report the row ever appears in. Hashing
|
||||
// costs nothing here — the value is never read back, only compared.
|
||||
//
|
||||
// Truncated to 32 hex characters: 128 bits, far beyond collision risk for a
|
||||
// counter, and it keeps the keys readable in a psql session while still being
|
||||
// irreversible.
|
||||
func Subject(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])[:32]
|
||||
}
|
||||
|
||||
// Allow records one request against a rule and reports whether it may proceed.
|
||||
//
|
||||
// The whole decision is one statement. It is worth reading, because everything
|
||||
// this package claims about concurrency rests on it:
|
||||
//
|
||||
// INSERT INTO rate_limits (bucket, window_start, count, expires_at)
|
||||
// VALUES ($1, $2, 1, $3)
|
||||
// ON CONFLICT (bucket, window_start)
|
||||
// DO UPDATE SET count = rate_limits.count + 1
|
||||
// RETURNING count
|
||||
//
|
||||
// The row is created or incremented, and the resulting count comes back, in one
|
||||
// round trip under one implicit transaction. Two instances racing on the same
|
||||
// bucket serialise on the primary key, and each sees a distinct count. There is
|
||||
// no read-then-write window for them to slip through.
|
||||
//
|
||||
// A request is counted even when it is refused. That is deliberate: a caller
|
||||
// hammering a limit should not be able to keep their own window open by
|
||||
// spending it, and the alternative — not counting refusals — makes the limit
|
||||
// cheaper to probe.
|
||||
func (l *Limiter) Allow(ctx context.Context, rule Rule, subject string) (Decision, error) {
|
||||
now := l.now()
|
||||
windowStart := now.Truncate(rule.Window)
|
||||
expiresAt := windowStart.Add(rule.Window)
|
||||
bucket := rule.Name + ":" + subject
|
||||
|
||||
var count int
|
||||
err := l.db.QueryRow(ctx,
|
||||
`INSERT INTO rate_limits (bucket, window_start, count, expires_at)
|
||||
VALUES ($1, $2, 1, $3)
|
||||
ON CONFLICT (bucket, window_start)
|
||||
DO UPDATE SET count = rate_limits.count + 1
|
||||
RETURNING count`,
|
||||
bucket, windowStart, expiresAt).Scan(&count)
|
||||
if err != nil {
|
||||
if l.failOpen {
|
||||
return Decision{Allowed: true, Remaining: rule.Limit, Limit: rule.Limit, Window: rule.Window},
|
||||
fmt.Errorf("ratelimit: %w", err)
|
||||
}
|
||||
return Decision{Allowed: false, RetryAfter: rule.Window, Limit: rule.Limit, Window: rule.Window},
|
||||
fmt.Errorf("ratelimit: %w", err)
|
||||
}
|
||||
|
||||
remaining := rule.Limit - count
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
return Decision{
|
||||
Allowed: count <= rule.Limit,
|
||||
Remaining: remaining,
|
||||
RetryAfter: expiresAt.Sub(now),
|
||||
Limit: rule.Limit,
|
||||
Window: rule.Window,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Sweep deletes expired counters, in bounded batches.
|
||||
//
|
||||
// Bounded because an unbounded DELETE on a busy table takes a lock for as long
|
||||
// as it takes to finish, and "as long as it takes" is not a number anyone can
|
||||
// predict at 3am. A batch of a few thousand rows completes in milliseconds and
|
||||
// can simply be run again.
|
||||
//
|
||||
// Safe to run concurrently: two sweeps delete disjoint sets because the
|
||||
// subquery re-reads under each statement's own snapshot, and a row deleted
|
||||
// twice is not an error.
|
||||
func (l *Limiter) Sweep(ctx context.Context, batch int) (int64, error) {
|
||||
if batch <= 0 {
|
||||
batch = 5000
|
||||
}
|
||||
tag, err := l.db.Exec(ctx,
|
||||
`DELETE FROM rate_limits
|
||||
WHERE ctid IN (
|
||||
SELECT ctid FROM rate_limits WHERE expires_at < $1 LIMIT $2
|
||||
)`,
|
||||
l.now(), batch)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ratelimit: sweep: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
452
go-api/internal/ratelimit/ratelimit_test.go
Normal file
452
go-api/internal/ratelimit/ratelimit_test.go
Normal file
@@ -0,0 +1,452 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/testutil"
|
||||
)
|
||||
|
||||
func newLimiter(t *testing.T) (*Limiter, *testutil.Harness, *time.Time) {
|
||||
t.Helper()
|
||||
h := testutil.New(t)
|
||||
clock := time.Now().Truncate(time.Hour) // a clean window boundary
|
||||
l := New(h.Pool).WithClock(func() time.Time { return clock })
|
||||
return l, h, &clock
|
||||
}
|
||||
|
||||
var testRule = Rule{Name: "test.rule", Limit: 3, Window: time.Minute}
|
||||
|
||||
func mustAllow(t *testing.T, l *Limiter, subject string) Decision {
|
||||
t.Helper()
|
||||
d, err := l.Allow(context.Background(), testRule, Subject(subject))
|
||||
if err != nil {
|
||||
t.Fatalf("Allow: %v", err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
/* ── The basic contract ─────────────────────────────────────────────────── */
|
||||
|
||||
func TestUnderAtAndOverTheLimit(t *testing.T) {
|
||||
l, _, _ := newLimiter(t)
|
||||
|
||||
// Under: each of the first three is allowed, and remaining counts down.
|
||||
for i := 1; i <= testRule.Limit; i++ {
|
||||
d := mustAllow(t, l, "alice")
|
||||
if !d.Allowed {
|
||||
t.Fatalf("request %d of %d was refused", i, testRule.Limit)
|
||||
}
|
||||
if want := testRule.Limit - i; d.Remaining != want {
|
||||
t.Errorf("request %d: remaining = %d, want %d", i, d.Remaining, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Over: the next one is refused and carries a usable Retry-After.
|
||||
d := mustAllow(t, l, "alice")
|
||||
if d.Allowed {
|
||||
t.Fatal("the request past the limit was allowed")
|
||||
}
|
||||
if d.Remaining != 0 {
|
||||
t.Errorf("remaining = %d, want 0", d.Remaining)
|
||||
}
|
||||
if d.RetryAfter <= 0 || d.RetryAfter > testRule.Window {
|
||||
t.Errorf("RetryAfter = %v, want a positive interval no longer than the window", d.RetryAfter)
|
||||
}
|
||||
}
|
||||
|
||||
// A refused request is still counted. Otherwise a caller at their limit could
|
||||
// keep probing for free, and the limit would be cheaper to test than to respect.
|
||||
func TestRefusedRequestsStillCount(t *testing.T) {
|
||||
l, h, _ := newLimiter(t)
|
||||
for i := 0; i < testRule.Limit+5; i++ {
|
||||
mustAllow(t, l, "bob")
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := h.Pool.QueryRow(context.Background(),
|
||||
`SELECT count FROM rate_limits WHERE bucket LIKE $1`, testRule.Name+":%").Scan(&count); err != nil {
|
||||
t.Fatalf("read counter: %v", err)
|
||||
}
|
||||
if count != testRule.Limit+5 {
|
||||
t.Errorf("count = %d, want %d — refusals must be counted too", count, testRule.Limit+5)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Windows ────────────────────────────────────────────────────────────── */
|
||||
|
||||
func TestTheWindowResets(t *testing.T) {
|
||||
l, _, clock := newLimiter(t)
|
||||
|
||||
for i := 0; i < testRule.Limit; i++ {
|
||||
mustAllow(t, l, "carol")
|
||||
}
|
||||
if mustAllow(t, l, "carol").Allowed {
|
||||
t.Fatal("expected to be at the limit")
|
||||
}
|
||||
|
||||
// Roll into the next window.
|
||||
*clock = clock.Add(testRule.Window)
|
||||
l.WithClock(func() time.Time { return *clock })
|
||||
|
||||
if d := mustAllow(t, l, "carol"); !d.Allowed {
|
||||
t.Error("the limit did not reset at the window boundary")
|
||||
} else if d.Remaining != testRule.Limit-1 {
|
||||
t.Errorf("remaining = %d, want %d after a reset", d.Remaining, testRule.Limit-1)
|
||||
}
|
||||
}
|
||||
|
||||
// A new window is a new ROW, not a reset of an existing counter. That is what
|
||||
// makes two instances rolling over simultaneously safe: neither clobbers the
|
||||
// other's increments.
|
||||
func TestANewWindowIsANewRow(t *testing.T) {
|
||||
l, h, clock := newLimiter(t)
|
||||
mustAllow(t, l, "dave")
|
||||
|
||||
*clock = clock.Add(testRule.Window)
|
||||
l.WithClock(func() time.Time { return *clock })
|
||||
mustAllow(t, l, "dave")
|
||||
|
||||
var rows int
|
||||
if err := h.Pool.QueryRow(context.Background(),
|
||||
`SELECT count(*) FROM rate_limits WHERE bucket LIKE $1`, testRule.Name+":%").Scan(&rows); err != nil {
|
||||
t.Fatalf("count rows: %v", err)
|
||||
}
|
||||
if rows != 2 {
|
||||
t.Errorf("%d rows, want 2 — each window must be its own row", rows)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Buckets are independent ────────────────────────────────────────────── */
|
||||
|
||||
func TestSubjectsAreIndependent(t *testing.T) {
|
||||
l, _, _ := newLimiter(t)
|
||||
|
||||
// Exhaust one subject entirely.
|
||||
for i := 0; i < testRule.Limit+2; i++ {
|
||||
mustAllow(t, l, "user-a")
|
||||
}
|
||||
// A different subject must be untouched.
|
||||
if d := mustAllow(t, l, "user-b"); !d.Allowed {
|
||||
t.Error("one subject's limit affected another's")
|
||||
}
|
||||
if d := mustAllow(t, l, "org-a|user-a"); !d.Allowed {
|
||||
t.Error("a compound subject collided with a simple one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesAreIndependent(t *testing.T) {
|
||||
l, _, _ := newLimiter(t)
|
||||
other := Rule{Name: "other.rule", Limit: 3, Window: time.Minute}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
mustAllow(t, l, "shared")
|
||||
}
|
||||
d, err := l.Allow(context.Background(), other, Subject("shared"))
|
||||
if err != nil {
|
||||
t.Fatalf("Allow: %v", err)
|
||||
}
|
||||
if !d.Allowed {
|
||||
t.Error("exhausting one rule exhausted another for the same subject")
|
||||
}
|
||||
}
|
||||
|
||||
/* ── No credential becomes a key ────────────────────────────────────────── */
|
||||
|
||||
// The property that matters most here: a bucket must never contain the thing it
|
||||
// identifies. A token in this table is a token in every EXPLAIN, every slow
|
||||
// query log and every backup.
|
||||
func TestSubjectsAreHashedNotStored(t *testing.T) {
|
||||
l, h, _ := newLimiter(t)
|
||||
|
||||
const secret = "a-very-secret-bearer-token-value"
|
||||
mustAllow(t, l, secret)
|
||||
|
||||
var found int
|
||||
if err := h.Pool.QueryRow(context.Background(),
|
||||
`SELECT count(*) FROM rate_limits WHERE bucket LIKE '%' || $1 || '%'`,
|
||||
secret).Scan(&found); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
if found != 0 {
|
||||
t.Error("the raw subject appears in the rate_limits table")
|
||||
}
|
||||
|
||||
// And the hash is stable, or a caller would get a fresh budget per request.
|
||||
if Subject(secret) != Subject(secret) {
|
||||
t.Error("Subject is not deterministic")
|
||||
}
|
||||
if Subject(secret) == secret {
|
||||
t.Error("Subject returned the raw value")
|
||||
}
|
||||
if len(Subject(secret)) != 32 {
|
||||
t.Errorf("Subject length = %d, want 32", len(Subject(secret)))
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Concurrency ────────────────────────────────────────────────────────── */
|
||||
|
||||
// The claim this package rests on: the count comes back from the statement that
|
||||
// increments it, so concurrent callers cannot both read the same value and both
|
||||
// proceed. Run with -race.
|
||||
func TestConcurrentCallersDoNotLoseIncrements(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
clock := time.Now().Truncate(time.Hour)
|
||||
l := New(h.Pool).WithClock(func() time.Time { return clock })
|
||||
|
||||
const callers = 40
|
||||
rule := Rule{Name: "concurrent.rule", Limit: 10, Window: time.Minute}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
allowed := 0
|
||||
|
||||
for i := 0; i < callers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
d, err := l.Allow(context.Background(), rule, Subject("hot-subject"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if d.Allowed {
|
||||
mu.Lock()
|
||||
allowed++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// EXACTLY the limit. Not "about" — if increments were lost, more would have
|
||||
// been allowed; if the statement were not atomic, the count would be wrong
|
||||
// in either direction.
|
||||
if allowed != rule.Limit {
|
||||
t.Errorf("%d of %d concurrent callers allowed, want exactly %d",
|
||||
allowed, callers, rule.Limit)
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := h.Pool.QueryRow(context.Background(),
|
||||
`SELECT count FROM rate_limits WHERE bucket LIKE $1`, rule.Name+":%").Scan(&count); err != nil {
|
||||
t.Fatalf("read counter: %v", err)
|
||||
}
|
||||
if count != callers {
|
||||
t.Errorf("counter = %d, want %d — increments were lost", count, callers)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Failure behaviour ──────────────────────────────────────────────────── */
|
||||
|
||||
// A limiter that cannot count must refuse by default. Failing open turns a
|
||||
// database blip into an unmetered window on the endpoints most worth guarding.
|
||||
func TestFailsClosedByDefault(t *testing.T) {
|
||||
l := New(brokenQuerier{}).WithClock(time.Now)
|
||||
|
||||
d, err := l.Allow(context.Background(), testRule, Subject("x"))
|
||||
if err == nil {
|
||||
t.Fatal("expected an error from a broken database")
|
||||
}
|
||||
if d.Allowed {
|
||||
t.Error("the limiter failed OPEN by default; it must fail closed")
|
||||
}
|
||||
if d.RetryAfter <= 0 {
|
||||
t.Error("a fail-closed decision carries no Retry-After")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailOpenIsOptIn(t *testing.T) {
|
||||
l := New(brokenQuerier{}).WithFailOpen(true)
|
||||
d, err := l.Allow(context.Background(), testRule, Subject("x"))
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if !d.Allowed {
|
||||
t.Error("WithFailOpen(true) did not permit the request")
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Sweep ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
func TestSweepRemovesOnlyExpiredWindows(t *testing.T) {
|
||||
l, h, clock := newLimiter(t)
|
||||
ctx := context.Background()
|
||||
|
||||
mustAllow(t, l, "old")
|
||||
|
||||
// Move past the old window, and open a new one.
|
||||
*clock = clock.Add(2 * testRule.Window)
|
||||
l.WithClock(func() time.Time { return *clock })
|
||||
mustAllow(t, l, "current")
|
||||
|
||||
removed, err := l.Sweep(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Sweep: %v", err)
|
||||
}
|
||||
if removed != 1 {
|
||||
t.Errorf("swept %d rows, want 1", removed)
|
||||
}
|
||||
|
||||
// The live window must survive.
|
||||
var remaining int
|
||||
_ = h.Pool.QueryRow(ctx, `SELECT count(*) FROM rate_limits`).Scan(&remaining)
|
||||
if remaining != 1 {
|
||||
t.Errorf("%d rows left, want 1 — the live window was swept", remaining)
|
||||
}
|
||||
|
||||
// Idempotent: a second sweep removes nothing and does not error.
|
||||
if again, err := l.Sweep(ctx, 100); err != nil || again != 0 {
|
||||
t.Errorf("second sweep: removed %d, err %v; want 0, nil", again, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepIsBounded(t *testing.T) {
|
||||
l, h, clock := newLimiter(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
mustAllow(t, l, "subject-"+strings.Repeat("x", i))
|
||||
}
|
||||
*clock = clock.Add(2 * testRule.Window)
|
||||
l.WithClock(func() time.Time { return *clock })
|
||||
|
||||
removed, err := l.Sweep(ctx, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("Sweep: %v", err)
|
||||
}
|
||||
if removed != 4 {
|
||||
t.Errorf("swept %d, want exactly the batch size 4", removed)
|
||||
}
|
||||
|
||||
var left int
|
||||
_ = h.Pool.QueryRow(ctx, `SELECT count(*) FROM rate_limits`).Scan(&left)
|
||||
if left != 6 {
|
||||
t.Errorf("%d rows left, want 6", left)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────────────────── */
|
||||
|
||||
// brokenQuerier fails every call, standing in for an unreachable database.
|
||||
//
|
||||
// Satisfies repo.Querier with pgx's real types, so this is the same interface
|
||||
// the production limiter takes — a hand-rolled stand-in would prove the code
|
||||
// works against a stand-in.
|
||||
type brokenQuerier struct{}
|
||||
|
||||
func (brokenQuerier) Query(context.Context, string, ...any) (pgx.Rows, error) {
|
||||
return nil, errBroken
|
||||
}
|
||||
|
||||
func (brokenQuerier) QueryRow(context.Context, string, ...any) pgx.Row { return brokenRow{} }
|
||||
|
||||
func (brokenQuerier) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) {
|
||||
return pgconn.CommandTag{}, errBroken
|
||||
}
|
||||
|
||||
type brokenRow struct{}
|
||||
|
||||
func (brokenRow) Scan(...any) error { return errBroken }
|
||||
|
||||
var errBroken = errString("ratelimit test: database unavailable")
|
||||
|
||||
type errString string
|
||||
|
||||
func (e errString) Error() string { return string(e) }
|
||||
|
||||
/* ── The fixed-window boundary, measured ────────────────────────────────── */
|
||||
|
||||
// The known limitation, asserted rather than assumed.
|
||||
//
|
||||
// A fixed window admits up to 2× the limit across a boundary: the limit at the
|
||||
// end of one window and the limit again at the start of the next. This test
|
||||
// measures that burst exactly, so the number in the documentation is a fact
|
||||
// rather than a claim, and so a future change to the algorithm has to
|
||||
// deliberately update it.
|
||||
func TestFixedWindowBoundaryBurstIsExactlyTwice(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
// Start just inside a window, so "end of window" is reachable.
|
||||
clock := time.Now().Truncate(time.Minute).Add(59 * time.Second)
|
||||
l := New(h.Pool).WithClock(func() time.Time { return clock })
|
||||
|
||||
rule := Rule{Name: "boundary.rule", Limit: 5, Window: time.Minute}
|
||||
allowed := 0
|
||||
|
||||
// Spend the whole limit at the very end of window 1.
|
||||
for i := 0; i < rule.Limit; i++ {
|
||||
d, err := l.Allow(context.Background(), rule, Subject("boundary"))
|
||||
if err != nil {
|
||||
t.Fatalf("Allow: %v", err)
|
||||
}
|
||||
if d.Allowed {
|
||||
allowed++
|
||||
}
|
||||
}
|
||||
|
||||
// One second later, window 2 begins.
|
||||
clock = clock.Add(time.Second)
|
||||
l.WithClock(func() time.Time { return clock })
|
||||
|
||||
for i := 0; i < rule.Limit; i++ {
|
||||
d, err := l.Allow(context.Background(), rule, Subject("boundary"))
|
||||
if err != nil {
|
||||
t.Fatalf("Allow: %v", err)
|
||||
}
|
||||
if d.Allowed {
|
||||
allowed++
|
||||
}
|
||||
}
|
||||
|
||||
// Exactly 2× the limit in just over a second. This is the documented
|
||||
// worst case — not worse, and not better.
|
||||
if allowed != rule.Limit*2 {
|
||||
t.Errorf("%d requests allowed across the boundary, want exactly %d (2× the limit)",
|
||||
allowed, rule.Limit*2)
|
||||
}
|
||||
|
||||
// And the burst does NOT continue: window 2's budget is now spent.
|
||||
d, err := l.Allow(context.Background(), rule, Subject("boundary"))
|
||||
if err != nil {
|
||||
t.Fatalf("Allow: %v", err)
|
||||
}
|
||||
if d.Allowed {
|
||||
t.Error("the burst continued past 2× the limit")
|
||||
}
|
||||
}
|
||||
|
||||
// Retry-After must point at the END of the current window, not at a fixed
|
||||
// duration. A client told to wait a whole window when the window is nearly over
|
||||
// waits twice as long as it needs to; one told to wait too little retries into
|
||||
// the same refusal.
|
||||
func TestRetryAfterPointsAtTheWindowBoundary(t *testing.T) {
|
||||
h := testutil.New(t)
|
||||
rule := Rule{Name: "retry.rule", Limit: 1, Window: time.Minute}
|
||||
|
||||
for _, offset := range []time.Duration{0, 15 * time.Second, 45 * time.Second, 59 * time.Second} {
|
||||
clock := time.Now().Truncate(time.Minute).Add(offset)
|
||||
l := New(h.Pool).WithClock(func() time.Time { return clock })
|
||||
subject := Subject("retry-" + offset.String())
|
||||
|
||||
// Spend the budget, then be refused.
|
||||
_, _ = l.Allow(context.Background(), rule, subject)
|
||||
d, err := l.Allow(context.Background(), rule, subject)
|
||||
if err != nil {
|
||||
t.Fatalf("Allow: %v", err)
|
||||
}
|
||||
if d.Allowed {
|
||||
t.Fatalf("offset %v: expected a refusal", offset)
|
||||
}
|
||||
|
||||
want := rule.Window - offset
|
||||
if d.RetryAfter != want {
|
||||
t.Errorf("offset %v: RetryAfter = %v, want %v (the remainder of the window)",
|
||||
offset, d.RetryAfter, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
163
go-api/internal/ratelimit/rules.go
Normal file
163
go-api/internal/ratelimit/rules.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package ratelimit
|
||||
|
||||
import "time"
|
||||
|
||||
// The rule set for the MCP and OAuth surface, in one place.
|
||||
//
|
||||
// Every number below is a judgement, so each carries the reasoning that
|
||||
// produced it. They are starting values: the right way to change one is to
|
||||
// change it here, with the comment updated, rather than to pass a different
|
||||
// number at a call site.
|
||||
//
|
||||
// TWO PRINCIPLES SHAPE ALL OF THEM
|
||||
//
|
||||
// 1. Limit the scarce thing, not the request. Registration writes a row for an
|
||||
// anonymous caller, so it is limited hard. A tool call reads rows the
|
||||
// caller may already read through the product, so it is limited loosely —
|
||||
// the cost there is database load, not access.
|
||||
//
|
||||
// 2. Key by the narrowest identity available. An IP is a whole office behind
|
||||
// NAT; a token is one connection. Limiting an authenticated endpoint by IP
|
||||
// would make one person's loop everyone's outage.
|
||||
|
||||
var (
|
||||
// Registration: 10 per hour per IP.
|
||||
//
|
||||
// The tightest limit here, because /oauth/register is the only endpoint
|
||||
// that WRITES for a caller with no credential at all — RFC 7591 requires
|
||||
// exactly that. A legitimate client registers once per installation and
|
||||
// then never again, so ten is already generous by two orders of magnitude;
|
||||
// it is set there only so a developer retrying a broken integration does
|
||||
// not lock themselves out.
|
||||
//
|
||||
// Keyed by IP because there is nothing else to key by: the caller is
|
||||
// anonymous by definition at this point.
|
||||
OAuthRegister = Rule{Name: "oauth.register", Limit: 10, Window: time.Hour}
|
||||
|
||||
// Authorization: 20 per hour per IP+user.
|
||||
//
|
||||
// A person clicking Approve does it once. Twenty allows for a browser
|
||||
// reload, a mistyped password, a client retrying a flow, and a developer
|
||||
// testing — and stops a script walking the authorization endpoint to farm
|
||||
// consent pages or probe client ids.
|
||||
//
|
||||
// IP AND user, not either alone: keying by user only would let one
|
||||
// attacker burn an innocent person's budget by naming them, and keying by
|
||||
// IP only would make an office share one person's allowance.
|
||||
OAuthAuthorize = Rule{Name: "oauth.authorize", Limit: 20, Window: time.Hour}
|
||||
|
||||
// Token exchange: 30 per hour per client.
|
||||
//
|
||||
// One exchange per authorization, and an authorization is already limited
|
||||
// above — so this is not the primary defence. It is here to bound
|
||||
// brute-forcing a code or a verifier: an authorization code lives 60
|
||||
// seconds and is single-use, and 30 attempts an hour makes guessing one
|
||||
// hopeless rather than merely improbable.
|
||||
OAuthToken = Rule{Name: "oauth.token", Limit: 30, Window: time.Hour}
|
||||
|
||||
// Refresh: 60 per hour per token family.
|
||||
//
|
||||
// An access token lives 15 minutes, so a well-behaved client refreshes
|
||||
// about 4 times an hour. Sixty leaves room for a client that refreshes
|
||||
// eagerly, or one running several sessions, while bounding a loop.
|
||||
//
|
||||
// Keyed by FAMILY rather than by token, because the token changes on every
|
||||
// rotation — keying by token would give each rotation a fresh budget,
|
||||
// which is the same as no budget at all.
|
||||
OAuthRefresh = Rule{Name: "oauth.refresh", Limit: 60, Window: time.Hour}
|
||||
|
||||
// MCP tool calls: 60 a minute, and 1000 an hour, per token.
|
||||
//
|
||||
// BOTH, because they stop different things. The minute limit stops a tight
|
||||
// loop — a model retrying a failing call, or a bug — from becoming a spike.
|
||||
// The hour limit stops a slow, sustained drain that would sit under the
|
||||
// minute limit forever: 59 calls a minute is 3,540 an hour, which is a lot
|
||||
// of queries for one connection.
|
||||
//
|
||||
// Sixty a minute is well above interactive use. A person asking questions
|
||||
// generates a handful of calls per turn, and a model doing several lookups
|
||||
// for one answer still lands in single figures.
|
||||
MCPToolCallPerMinute = Rule{Name: "mcp.call.min", Limit: 60, Window: time.Minute}
|
||||
MCPToolCallPerHour = Rule{Name: "mcp.call.hour", Limit: 1000, Window: time.Hour}
|
||||
|
||||
// Per-organisation ceiling: 5000 an hour.
|
||||
//
|
||||
// The backstop for the case the per-token limits cannot see: one tenant
|
||||
// with many connected clients, each individually well-behaved, together
|
||||
// saturating the database. Set well above the sum of a few active users so
|
||||
// it is never reached in ordinary use — it exists to bound a runaway, not
|
||||
// to ration normal work.
|
||||
MCPPerOrgPerHour = Rule{Name: "mcp.org.hour", Limit: 5000, Window: time.Hour}
|
||||
)
|
||||
|
||||
// A note on what is NOT rate limited here, and why.
|
||||
//
|
||||
// CONCURRENT CONNECTIONS. The plan proposed 10 concurrent MCP connections per
|
||||
// user. That is not implemented, and it is not an oversight: this transport is
|
||||
// stateless — one POST per message, no session, nothing held open — so there is
|
||||
// no such thing as a concurrent connection to count. The thing that limit was
|
||||
// reaching for is request rate, and the two limits above are that, measured
|
||||
// directly. Implementing a connection counter over a stateless endpoint would
|
||||
// mean inventing connection state purely so it could be limited.
|
||||
//
|
||||
// DISCOVERY. The two .well-known documents are static, cacheable for five
|
||||
// minutes, and contain public URLs. Limiting them would add a database write to
|
||||
// the cheapest endpoints on the surface, to protect nothing.
|
||||
//
|
||||
// REVOCATION. Deliberately unlimited. Revocation is the thing a person reaches
|
||||
// for when something has gone wrong, and an attacker gains nothing by calling
|
||||
// it — the worst they can do is revoke tokens they already hold. Rate limiting
|
||||
// the emergency brake is the wrong trade.
|
||||
|
||||
/*
|
||||
FAILURE BEHAVIOUR, RULE BY RULE
|
||||
===============================
|
||||
|
||||
The question this section answers: when the database cannot be reached, does a
|
||||
request get through?
|
||||
|
||||
EVERY RULE HERE FAILS CLOSED. Limiter.failOpen defaults to false and nothing in
|
||||
this service sets it to true. The reasoning is the same for all of them and is
|
||||
worth stating once rather than per-rule:
|
||||
|
||||
- A limiter that cannot count is not limiting. If a database outage lifted
|
||||
the limits, then the moment the system is least able to absorb load is
|
||||
exactly the moment its protections switch off — and an attacker who can
|
||||
cause or wait for a blip gets an unmetered window on the endpoints that
|
||||
write rows for anonymous callers.
|
||||
|
||||
- The cost of failing closed is bounded and visible: MCP returns 429 and
|
||||
Claude retries. The cost of failing open is unbounded and silent.
|
||||
|
||||
- These endpoints are not load-bearing for the product. If the database is
|
||||
down, /oauth/token cannot mint a token and /mcp cannot read a row anyway;
|
||||
the limiter refusing first changes the error message, not the outcome.
|
||||
|
||||
WHAT IS EXPLICITLY NOT FAIL-OPEN, AND WHY IT MATTERS MOST
|
||||
|
||||
oauth.register Writes a row for a caller with no credential. Failing open
|
||||
here is an unauthenticated write endpoint with no ceiling.
|
||||
oauth.token Bounds brute-forcing a code or a verifier. Failing open
|
||||
turns a 60-second, single-use code into one an attacker may
|
||||
guess at without limit for the duration of the outage.
|
||||
oauth.refresh Failing open removes the bound on a loop against a
|
||||
long-lived credential.
|
||||
|
||||
THE ONE PLACE FAIL-OPEN WOULD BE DEFENSIBLE
|
||||
|
||||
A deployment that would rather serve MCP degraded than refuse it can call
|
||||
WithFailOpen(true) on the limiter used for the mcp.* rules only — those guard
|
||||
database load rather than access, and every call behind them is already
|
||||
authenticated and already authorized by the policy table. That is a deliberate
|
||||
operational trade, it is one line, and it is deliberately not the default.
|
||||
|
||||
It must NOT be applied to the oauth.* rules. Those guard the credential issuance
|
||||
path, where the thing being limited is an attacker's number of attempts.
|
||||
|
||||
OBSERVABILITY
|
||||
|
||||
A limiter failure is logged at ERROR by the middleware (httpserver/mcplimit.go)
|
||||
with the rule name and the decision, never the subject — the subject is a hash
|
||||
of a credential. A sustained run of those log lines means the limiter is not
|
||||
limiting, and is worth an alert.
|
||||
*/
|
||||
Reference in New Issue
Block a user