From 3b80d2ecf700312457d404dec5c17c9a20fa837d Mon Sep 17 00:00:00 2001 From: CMGS Date: Fri, 25 Sep 2026 17:41:05 +0800 Subject: [PATCH 1/4] fix(control-plane): login refuses an email longer than 254 bytes before it keys the throttle The throttle kept client IP plus the normalized email for five minutes, and the body limit is 1 MiB, so unauthenticated logins with unique megabyte emails held a megabyte each (64 such logins retained 65 MiB). An address longer than the SMTP limit cannot belong to an account, so it gets the usual 401 without a throttle entry. --- control-plane/internal/httpapi/auth.go | 4 ++++ control-plane/internal/httpapi/server.go | 1 + control-plane/internal/httpapi/server_test.go | 16 ++++++++++++++++ 3 files changed, 21 insertions(+) diff --git a/control-plane/internal/httpapi/auth.go b/control-plane/internal/httpapi/auth.go index 52f5fa1..724ea2f 100644 --- a/control-plane/internal/httpapi/auth.go +++ b/control-plane/internal/httpapi/auth.go @@ -32,6 +32,10 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { if !decodeJSON(w, r, maxJSONBody, &body) { return } + if len(body.Email) > maxEmailLen { + writeError(w, http.StatusUnauthorized, "invalid email or password") + return + } throttleKey := s.clientIP(r) + "|" + user.NormalizeEmail(body.Email) if !s.throttle.allow(throttleKey, time.Now()) { writeError(w, http.StatusTooManyRequests, "too many login attempts; retry later") diff --git a/control-plane/internal/httpapi/server.go b/control-plane/internal/httpapi/server.go index 4d62377..8a48a84 100644 --- a/control-plane/internal/httpapi/server.go +++ b/control-plane/internal/httpapi/server.go @@ -26,6 +26,7 @@ import ( const ( sessionCookie = "cp_session" maxJSONBody = 1 << 20 + maxEmailLen = 254 maxConfigBody = 4 << 20 statusField = "status" ) diff --git a/control-plane/internal/httpapi/server_test.go b/control-plane/internal/httpapi/server_test.go index 19af768..0ec9ce5 100644 --- a/control-plane/internal/httpapi/server_test.go +++ b/control-plane/internal/httpapi/server_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "strconv" + "strings" "testing" "time" @@ -229,6 +230,21 @@ func TestLoginThrottleSweepStaysAmortisedAndReclaimsExpired(t *testing.T) { } } +func TestLoginRejectsAnOverlongEmailBeforeTheThrottle(t *testing.T) { + s := newTestServer(t, usermemory.New(), false) + body, _ := json.Marshal(map[string]string{"email": strings.Repeat("a", 1<<16) + "@example.com", "password": "x"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + if n := len(s.throttle.windows); n != 0 { + t.Fatalf("throttle windows = %d, want none kept for an overlong email", n) + } +} + type loginState struct { cookie *http.Cookie csrf string From 3defcba647d75e5e3ee828749f92f5063878cb6b Mon Sep 17 00:00:00 2001 From: CMGS Date: Fri, 25 Sep 2026 17:41:45 +0800 Subject: [PATCH 2/4] fix(control-plane): a transient user-store error answers 503 and keeps the session requireAuth deleted the session on any ByID error, so a Postgres restart or failover logged every active admin out for good. Only a missing, disabled or password-reset user drops the session now; any other store error is logged and answered 503. --- control-plane/internal/httpapi/server.go | 5 +++ control-plane/internal/httpapi/server_test.go | 41 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/control-plane/internal/httpapi/server.go b/control-plane/internal/httpapi/server.go index 8a48a84..f8c9199 100644 --- a/control-plane/internal/httpapi/server.go +++ b/control-plane/internal/httpapi/server.go @@ -115,6 +115,11 @@ func (s *Server) requireAuth(next http.Handler) http.Handler { return } u, err := s.users.ByID(r.Context(), session.UserID) + if err != nil && !errors.Is(err, user.ErrNotFound) { + log.WithFunc("httpapi.requireAuth").Errorf(r.Context(), err, "load session user rid=%s", gateway.RequestIDFrom(r.Context())) + writeError(w, http.StatusServiceUnavailable, "authentication is temporarily unavailable") + return + } if err != nil || u.Disabled || (u.PasswordChangedAt > 0 && session.IssuedAt <= u.PasswordChangedAt) { _ = s.sessions.Delete(r.Context(), session.ID) writeError(w, http.StatusUnauthorized, "authentication required") diff --git a/control-plane/internal/httpapi/server_test.go b/control-plane/internal/httpapi/server_test.go index 0ec9ce5..86cbf00 100644 --- a/control-plane/internal/httpapi/server_test.go +++ b/control-plane/internal/httpapi/server_test.go @@ -2,11 +2,14 @@ package httpapi import ( "bytes" + "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "strconv" "strings" + "sync/atomic" "testing" "time" @@ -245,6 +248,32 @@ func TestLoginRejectsAnOverlongEmailBeforeTheThrottle(t *testing.T) { } } +func TestSessionSurvivesATransientUserStoreError(t *testing.T) { + store := &flakyStore{Store: usermemory.New()} + hash, err := auth.HashPassword("password123!") + if err != nil { + t.Fatalf("hash password: %v", err) + } + now := time.Now().Unix() + if err := store.Create(t.Context(), user.User{ + ID: "admin", Email: "admin@example.com", DisplayName: "admin", PasswordHash: hash, + Role: user.RoleSystemAdmin, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("seed user: %v", err) + } + handler := newTestServer(t, store, false).Handler() + admin := loginAs(t, handler, "admin@example.com") + + store.fail.Store(true) + if rec := request(t, handler, admin, http.MethodGet, "/api/v1/session", nil, false); rec.Code != http.StatusServiceUnavailable { + t.Fatalf("session status during a store outage = %d, want 503", rec.Code) + } + store.fail.Store(false) + if rec := request(t, handler, admin, http.MethodGet, "/api/v1/session", nil, false); rec.Code != http.StatusOK { + t.Fatalf("session status after the store recovered = %d, want 200", rec.Code) + } +} + type loginState struct { cookie *http.Cookie csrf string @@ -328,3 +357,15 @@ func request(t *testing.T, handler http.Handler, state loginState, method, path handler.ServeHTTP(rec, req) return rec } + +type flakyStore struct { + user.Store + fail atomic.Bool +} + +func (f *flakyStore) ByID(ctx context.Context, id string) (user.User, error) { + if f.fail.Load() { + return user.User{}, errors.New("connection refused") + } + return f.Store.ByID(ctx, id) +} From 5ee163321501ad86a01cc1f2049e9ae7db321b74 Mon Sep 17 00:00:00 2001 From: CMGS Date: Fri, 25 Sep 2026 17:42:11 +0800 Subject: [PATCH 3/4] review(control-plane): own-line comments start uppercase unless they open with an identifier --- control-plane/internal/httpapi/admin.go | 2 +- control-plane/internal/httpapi/server.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/control-plane/internal/httpapi/admin.go b/control-plane/internal/httpapi/admin.go index 0f9bc53..1da9664 100644 --- a/control-plane/internal/httpapi/admin.go +++ b/control-plane/internal/httpapi/admin.go @@ -102,7 +102,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - // evict sessions issued before the reset so a stolen cookie dies too + // Evict sessions issued before the reset so a stolen cookie dies too u.PasswordChangedAt = time.Now().Unix() } if body.Tenant != nil { diff --git a/control-plane/internal/httpapi/server.go b/control-plane/internal/httpapi/server.go index f8c9199..fc97677 100644 --- a/control-plane/internal/httpapi/server.go +++ b/control-plane/internal/httpapi/server.go @@ -292,7 +292,7 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, limit int64, value any) func writeJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) - // the status line is already committed; an encode error is a gone client + // The status line is already committed; an encode error is a gone client _ = json.NewEncoder(w).Encode(value) } From ee0c2264471d372ef3d2f7e5031629e47a591d7b Mon Sep 17 00:00:00 2001 From: CMGS Date: Sat, 26 Sep 2026 17:43:00 +0800 Subject: [PATCH 4/4] docs: error statuses as the gateway renders them, the MCP prose review scope, the live-matrix group argument, login and session responses in the OpenAPI --- control-plane/api/openapi.yaml | 3 +++ docs/api.md | 5 +++-- docs/examples.md | 2 +- docs/governance.md | 6 +++--- docs/security.md | 6 ++++-- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/control-plane/api/openapi.yaml b/control-plane/api/openapi.yaml index 1cbd991..9560969 100644 --- a/control-plane/api/openapi.yaml +++ b/control-plane/api/openapi.yaml @@ -31,6 +31,8 @@ paths: responses: "200": {$ref: "#/components/responses/Session"} "401": {$ref: "#/components/responses/Error"} + "429": {$ref: "#/components/responses/Error"} + "500": {$ref: "#/components/responses/Error"} /auth/logout: post: summary: End the current session @@ -42,6 +44,7 @@ paths: responses: "200": {$ref: "#/components/responses/Session"} "401": {$ref: "#/components/responses/Error"} + "503": {$ref: "#/components/responses/Error"} /overview: get: summary: Scoped usage, trend and model availability diff --git a/docs/api.md b/docs/api.md index 6ecc319..9ff0dd2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -271,8 +271,9 @@ against `max_live_streams_per_key`. JSON-RPC batches are refused (400); the key's QPS and the tenant's pooled QPS apply. A server declared with `oauth` is called with an access token the gateway fetches from the server's token endpoint; a `401` from the server fetches a fresh token and retries the call -once. Upstream failures answer a generic `502`; the server's endpoint and the -identity provider's error text stay in the gateway log. +once. Upstream failures answer a generic `500` (`internal_server_exception`); +the server's endpoint and the identity provider's error text stay in the +gateway log. ## Batch & files diff --git a/docs/examples.md b/docs/examples.md index 5a8608b..4f15b3e 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -204,7 +204,7 @@ Price the chat models by their reasoning too: xAI's chat wire reports `total_tokens`, and the gateway reads that arithmetic and bills them at the output rate. Every reply carries `usage.cost_in_usd_ticks`, so with list prices configured the ledger's `cost_micros` matches the vendor's own charge — the -check `scripts/live-matrix/live_matrix.py --group xai` runs. +check `scripts/live-matrix/live_matrix.py xai` runs. The same models are served by Bedrock and OpenRouter, both of which normalize that usage shape to the OpenAI one: diff --git a/docs/governance.md b/docs/governance.md index e9c34d3..17bef2b 100644 --- a/docs/governance.md +++ b/docs/governance.md @@ -165,9 +165,9 @@ secret and refresh-token seed are read from the environment at fetch time. A tenant whose `security.moderate` is on has every served `tools/call`, `resources/read` and `prompts/get` result reviewed by the moderator behind `moderation:` — the same review the chat and realtime surfaces apply to -inbound text, here over every prose field of the result (string values under -`result`, joined by newlines; `blob`, `mimeType`, `name`, `type` and `uri` -fields are identifiers or binary and pass untouched). The reply is buffered +inbound text, here over every string value under `result`, joined by +newlines, except the base64 `blob` and `data` of an image or audio block or a +blob resource, which pass untouched. The reply is buffered for the review, up to the server's `max_reply_bytes`. A mask rewrites the text in place (`[MASKED]`); a denial, a degrade verdict, a mask that matches nothing, or a reply the gateway cannot parse replaces the whole reply with one diff --git a/docs/security.md b/docs/security.md index 4cd1544..72d7bc1 100644 --- a/docs/security.md +++ b/docs/security.md @@ -95,8 +95,10 @@ Stated so an operator can choose them deliberately: (default) denies the request or tool result, `true` admits it. - Upstream failure: account failover within the model, then the model's `fallback_models` chain, only while nothing has been sent to the client. - Upstream and identity-provider errors reach the customer as a generic - `502`; endpoints and error text stay in the gateway log. + A terminal model failure then answers `424` with + `model_error_exception` (see [API](api.md)); an MCP server or + identity-provider failure answers a generic `500`, and its endpoint and + error text stay in the gateway log. - Abuse of long-lived connections: realtime sessions and MCP listen streams are capped per key (`max_live_streams_per_key`); realtime turns and every MCP request spend the key's QPS permits; request bodies are capped at