mcp connection
This commit is contained in:
294
go-api/internal/httpserver/proxybuckets_test.go
Normal file
294
go-api/internal/httpserver/proxybuckets_test.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package httpserver_test
|
||||
|
||||
// Multi-client rate-limit isolation, end to end.
|
||||
//
|
||||
// WHAT THIS IS FOR
|
||||
//
|
||||
// clientip_test.go proves the address RESOLVER picks the right string. It does
|
||||
// not prove the string reaches Postgres as a distinct bucket, that the mounted
|
||||
// route uses the resolver at all, or that the OAuth registration limit is
|
||||
// actually per-client once it does. Those are different failures — a correct
|
||||
// resolver wired to nothing looks identical from a unit test — and they are
|
||||
// what broke in production, so they are tested here against the real handler
|
||||
// and the real limiter.
|
||||
//
|
||||
// Every test drives httpserver.Handler() through the full middleware stack with
|
||||
// a real database behind it. Nothing is stubbed.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/config"
|
||||
"github.com/krow/krow-backend/go-api/internal/db"
|
||||
"github.com/krow/krow-backend/go-api/internal/httpserver"
|
||||
"github.com/krow/krow-backend/go-api/internal/ratelimit"
|
||||
"github.com/krow/krow-backend/go-api/internal/testutil"
|
||||
)
|
||||
|
||||
// The proxy this fixture's deployment sits behind, and an address inside it.
|
||||
const (
|
||||
proxyNetwork = "10.42.0.0/16"
|
||||
proxyAddr = "10.42.0.1:9999"
|
||||
)
|
||||
|
||||
// proxiedAPI is newOAuthAPI with a trusted proxy configured.
|
||||
//
|
||||
// Deliberately not a flag on newOAuthAPI: every existing test in this package
|
||||
// must keep running with an EMPTY trusted set, because that is the default
|
||||
// posture and a regression in it is the thing worth catching.
|
||||
type proxiedAPI struct {
|
||||
handler http.Handler
|
||||
h *testutil.Harness
|
||||
}
|
||||
|
||||
func newProxiedAPI(t *testing.T) *proxiedAPI {
|
||||
t.Helper()
|
||||
h := testutil.New(t)
|
||||
|
||||
network, err := netip.ParsePrefix(proxyNetwork)
|
||||
if err != nil {
|
||||
t.Fatalf("bad test network: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
AppEnv: "development",
|
||||
HTTP: config.HTTPConfig{
|
||||
Host: "127.0.0.1", Port: 0, ShutdownTimeout: time.Second,
|
||||
TrustedProxies: []netip.Prefix{network.Masked()},
|
||||
},
|
||||
DB: config.DBConfig{Schema: "public"},
|
||||
OAuth: config.OAuthConfig{
|
||||
Issuer: testOAuthIssuer,
|
||||
Resource: testMCPResource,
|
||||
LoginPath: "/login",
|
||||
},
|
||||
}
|
||||
srv, err := httpserver.New(cfg, &db.DB{Pool: h.Pool, Schema: "public"},
|
||||
slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("build the server: %v", err)
|
||||
}
|
||||
return &proxiedAPI{handler: srv.Handler(), h: h}
|
||||
}
|
||||
|
||||
// register performs one DCR as a caller arriving via the proxy.
|
||||
//
|
||||
// peer is what net/http would report as RemoteAddr; forwarded is the
|
||||
// X-Forwarded-For the proxy appended. An empty forwarded value sends no header.
|
||||
func (a *proxiedAPI) register(t *testing.T, peer, forwarded, name string) int {
|
||||
t.Helper()
|
||||
body := fmt.Sprintf(
|
||||
`{"client_name":%q,"redirect_uris":["https://client.example.test/cb"]}`, name)
|
||||
req, err := http.NewRequest("POST", "/oauth/register", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.RemoteAddr = peer
|
||||
if forwarded != "" {
|
||||
req.Header.Set("X-Forwarded-For", forwarded)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
a.handler.ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
// exhaust registers until the limit refuses, and returns how many succeeded.
|
||||
// It stops well past the limit so a failure reports a number rather than hanging.
|
||||
func (a *proxiedAPI) exhaust(t *testing.T, peer, forwarded, name string) int {
|
||||
t.Helper()
|
||||
allowed := 0
|
||||
for i := 0; i < ratelimit.OAuthRegister.Limit*3; i++ {
|
||||
code := a.register(t, peer, forwarded, fmt.Sprintf("%s-%d", name, i))
|
||||
if code == http.StatusTooManyRequests {
|
||||
return allowed
|
||||
}
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("%s attempt %d: unexpected status %d", name, i, code)
|
||||
}
|
||||
allowed++
|
||||
}
|
||||
t.Fatalf("%s was never refused after %d registrations; the limit is not applied",
|
||||
name, ratelimit.OAuthRegister.Limit*3)
|
||||
return allowed
|
||||
}
|
||||
|
||||
/* ── Ten independent clients ────────────────────────────────────────────── */
|
||||
|
||||
// The headline requirement: many users behind one proxy each get their own
|
||||
// budget. Before this change every one of these shared a bucket and the
|
||||
// eleventh registration on the list would have been refused.
|
||||
func TestTenClientsBehindOneProxyDoNotShareABucket(t *testing.T) {
|
||||
a := newProxiedAPI(t)
|
||||
|
||||
const clients = 10
|
||||
for i := 0; i < clients; i++ {
|
||||
client := fmt.Sprintf("203.0.113.%d", i+1)
|
||||
// Each client registers TWICE — Claude mints a new client per connect,
|
||||
// so a reconnect must not count against anybody else.
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
code := a.register(t, proxyAddr, client+", "+"10.42.0.1",
|
||||
fmt.Sprintf("client-%d-%d", i, attempt))
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("client %s attempt %d: status %d, want 201 — clients are sharing a bucket",
|
||||
client, attempt, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Twenty registrations went through on a limit of ten per subject, which is
|
||||
// only possible if the subject really is the forwarded client.
|
||||
var buckets int
|
||||
if err := a.h.Pool.QueryRow(t.Context(),
|
||||
`SELECT count(*) FROM rate_limits WHERE bucket LIKE 'oauth.register:%'`).Scan(&buckets); err != nil {
|
||||
t.Fatalf("count buckets: %v", err)
|
||||
}
|
||||
if buckets != clients {
|
||||
t.Errorf("%d distinct oauth.register buckets, want %d — one per client", buckets, clients)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── One client's exhaustion is its own ─────────────────────────────────── */
|
||||
|
||||
// Client A burns its whole budget; client B is unaffected. This is the property
|
||||
// that failed in production, where A's retries refused B outright.
|
||||
func TestOneClientExhaustingDoesNotBlockAnother(t *testing.T) {
|
||||
a := newProxiedAPI(t)
|
||||
|
||||
const clientA, clientB = "203.0.113.50", "203.0.113.51"
|
||||
|
||||
allowed := a.exhaust(t, proxyAddr, clientA, "A")
|
||||
if allowed != ratelimit.OAuthRegister.Limit {
|
||||
t.Errorf("client A got %d registrations, want %d", allowed, ratelimit.OAuthRegister.Limit)
|
||||
}
|
||||
|
||||
// A is now refused.
|
||||
if code := a.register(t, proxyAddr, clientA, "A-again"); code != http.StatusTooManyRequests {
|
||||
t.Errorf("client A after exhausting: status %d, want 429", code)
|
||||
}
|
||||
// B is not.
|
||||
if code := a.register(t, proxyAddr, clientB, "B"); code != http.StatusCreated {
|
||||
t.Errorf("client B: status %d, want 201 — A's exhaustion blocked B", code)
|
||||
}
|
||||
}
|
||||
|
||||
// A single client reconnecting repeatedly spends only its own budget, which is
|
||||
// what Claude actually does: a new DCR client on every connect.
|
||||
func TestRepeatedReconnectConsumesOnlyThatClientsBudget(t *testing.T) {
|
||||
a := newProxiedAPI(t)
|
||||
|
||||
const reconnecting = "203.0.113.60"
|
||||
a.exhaust(t, proxyAddr, reconnecting, "reconnector")
|
||||
|
||||
// Nine other clients are untouched by it.
|
||||
for i := 0; i < 9; i++ {
|
||||
other := fmt.Sprintf("198.51.100.%d", i+1)
|
||||
if code := a.register(t, proxyAddr, other, fmt.Sprintf("other-%d", i)); code != http.StatusCreated {
|
||||
t.Fatalf("client %s: status %d, want 201", other, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Spoofing still buys nothing ────────────────────────────────────────── */
|
||||
|
||||
// A caller reaching the API directly — not through the proxy — cannot escape
|
||||
// its bucket by varying X-Forwarded-For. It gets ONE budget however many
|
||||
// different values it sends.
|
||||
func TestUntrustedCallerCannotEscapeItsBucketByForging(t *testing.T) {
|
||||
a := newProxiedAPI(t)
|
||||
|
||||
const direct = "198.51.100.200:40000" // outside proxyNetwork
|
||||
|
||||
allowed := 0
|
||||
for i := 0; i < ratelimit.OAuthRegister.Limit*2; i++ {
|
||||
// A different forged client on every single request.
|
||||
code := a.register(t, direct, fmt.Sprintf("203.0.113.%d", i+100), fmt.Sprintf("forger-%d", i))
|
||||
if code == http.StatusTooManyRequests {
|
||||
break
|
||||
}
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("attempt %d: unexpected status %d", i, code)
|
||||
}
|
||||
allowed++
|
||||
}
|
||||
|
||||
if allowed != ratelimit.OAuthRegister.Limit {
|
||||
t.Errorf("a forging caller got %d registrations, want %d — the header bought extra budget",
|
||||
allowed, ratelimit.OAuthRegister.Limit)
|
||||
}
|
||||
|
||||
var buckets int
|
||||
if err := a.h.Pool.QueryRow(t.Context(),
|
||||
`SELECT count(*) FROM rate_limits WHERE bucket LIKE 'oauth.register:%'`).Scan(&buckets); err != nil {
|
||||
t.Fatalf("count buckets: %v", err)
|
||||
}
|
||||
if buckets != 1 {
|
||||
t.Errorf("a forging caller produced %d buckets, want exactly 1", buckets)
|
||||
}
|
||||
}
|
||||
|
||||
// Claiming to be the trusted proxy does not make a caller trusted.
|
||||
func TestClaimingToBeTheProxyDoesNotWork(t *testing.T) {
|
||||
a := newProxiedAPI(t)
|
||||
|
||||
const direct = "198.51.100.201:40000"
|
||||
// The forged chain ends in the proxy's own address, which is what an
|
||||
// attacker who has read this file would try.
|
||||
if code := a.register(t, direct, "203.0.113.9, 10.42.0.1", "impostor"); code != http.StatusCreated {
|
||||
t.Fatalf("setup: status %d", code)
|
||||
}
|
||||
|
||||
var bucket string
|
||||
if err := a.h.Pool.QueryRow(t.Context(),
|
||||
`SELECT bucket FROM rate_limits WHERE bucket LIKE 'oauth.register:%'`).Scan(&bucket); err != nil {
|
||||
t.Fatalf("read bucket: %v", err)
|
||||
}
|
||||
// The bucket must be the DIRECT caller's address, not the forged one.
|
||||
want := "oauth.register:" + ratelimit.Subject("198.51.100.201")
|
||||
if bucket != want {
|
||||
t.Errorf("bucket = %q, want %q — a forged chain was believed", bucket, want)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── The default posture is unchanged ───────────────────────────────────── */
|
||||
|
||||
// With no trusted proxies configured — the default, and how every other test in
|
||||
// this package runs — the forwarded header is ignored and callers share the
|
||||
// peer's bucket exactly as before.
|
||||
func TestWithoutTrustedProxiesCallersShareThePeerBucket(t *testing.T) {
|
||||
a := newOAuthAPI(t) // no TrustedProxies in its config
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
body := fmt.Sprintf(
|
||||
`{"client_name":"unproxied-%d","redirect_uris":["https://client.example.test/cb"]}`, i)
|
||||
req, err := http.NewRequest("POST", "/oauth/register", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.RemoteAddr = "192.0.2.10:5000"
|
||||
req.Header.Set("X-Forwarded-For", fmt.Sprintf("203.0.113.%d", i+1))
|
||||
rec := httptest.NewRecorder()
|
||||
a.handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("attempt %d: status %d", i, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
var buckets int
|
||||
if err := a.h.Pool.QueryRow(t.Context(),
|
||||
`SELECT count(*) FROM rate_limits WHERE bucket LIKE 'oauth.register:%'`).Scan(&buckets); err != nil {
|
||||
t.Fatalf("count buckets: %v", err)
|
||||
}
|
||||
if buckets != 1 {
|
||||
t.Errorf("%d buckets with no trusted proxy configured, want 1 — the header was read", buckets)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user