From 1c13d2265bfb26fe92af5e6ebee66cafacabec3a Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 11 Aug 2026 10:13:10 +0400 Subject: [PATCH] Score the boundary against wrong credentials, not just right ones (V-673) Every shape of wrong credential gets a case: no header, wrong token, a prefix of the token, the token with no scheme, and Basic. Plus the two the allowlist exists for, /slots and its save action, and the caps. The readiness test now posts to /v1/chat/completions. The allowlist sits in front of the readiness check and answers 405 to a method mavgpud never serves, so the old GET measured the allowlist rather than the 503. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ESv8hqNPseYt1CnotZpqDz --- cmd/mavgpud/auth_test.go | 180 +++++++++++++++++++++++++++++++++++++++ cmd/mavgpud/gpu_test.go | 6 +- 2 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 cmd/mavgpud/auth_test.go diff --git a/cmd/mavgpud/auth_test.go b/cmd/mavgpud/auth_test.go new file mode 100644 index 0000000..830b0d4 --- /dev/null +++ b/cmd/mavgpud/auth_test.go @@ -0,0 +1,180 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// ok is what the boundary is protecting: anything that reaches it has spent +// the card. +func ok(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) } + +func TestRequireTokenRefusesEveryWrongCredential(t *testing.T) { + h := requireToken("s3cret", 1<<20, http.HandlerFunc(ok)) + cases := []struct { + name string + auth string + want int + }{ + {"no header", "", http.StatusUnauthorized}, + {"wrong token", "Bearer wrong", http.StatusUnauthorized}, + {"prefix of the token", "Bearer s3cre", http.StatusUnauthorized}, + {"token with no scheme", "s3cret", http.StatusUnauthorized}, + {"basic auth", "Basic czNjcmV0", http.StatusUnauthorized}, + {"right token", "Bearer s3cret", http.StatusTeapot}, + {"scheme is case-insensitive", "bearer s3cret", http.StatusTeapot}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/health", nil) + if tc.auth != "" { + r.Header.Set("Authorization", tc.auth) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != tc.want { + t.Errorf("status %d, want %d", w.Code, tc.want) + } + }) + } +} + +// The 401 must not say which part was wrong. A probe that can tell a malformed +// header from a wrong token learns the header shape for free. +func TestUnauthorizedSaysNothingUseful(t *testing.T) { + h := requireToken("s3cret", 1<<20, http.HandlerFunc(ok)) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil)) + if got := strings.TrimSpace(w.Body.String()); got != "unauthorized" { + t.Errorf("body %q, want %q", got, "unauthorized") + } + if got := w.Header().Get("WWW-Authenticate"); got != "Bearer" { + t.Errorf("WWW-Authenticate %q, want Bearer", got) + } +} + +// The body cap applies to an authenticated request. An unauthenticated one +// never gets far enough to allocate anything. +func TestRequireTokenCapsTheBody(t *testing.T) { + var read error + h := requireToken("t", 8, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := make([]byte, 64) + for read == nil { + if _, read = r.Body.Read(buf); read != nil { + break + } + } + })) + r := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(strings.Repeat("x", 4096))) + r.Header.Set("Authorization", "Bearer t") + h.ServeHTTP(httptest.NewRecorder(), r) + if read == nil || !strings.Contains(read.Error(), "too large") { + t.Errorf("read error %v, want the body cap", read) + } +} + +func TestAllowlistRefusesWhatMavenDoesNotCall(t *testing.T) { + h := allowlist(http.HandlerFunc(ok)) + cases := []struct { + method, path string + want int + }{ + {http.MethodPost, "/v1/chat/completions", http.StatusTeapot}, + {http.MethodGet, "/v1/models", http.StatusTeapot}, + // /slots returns the prompts of whoever else is using the card, and + // its actions write files the request names. + {http.MethodGet, "/slots", http.StatusNotFound}, + {http.MethodPost, "/slots/0?action=save", http.StatusNotFound}, + {http.MethodGet, "/", http.StatusNotFound}, + {http.MethodGet, "/v1/chat/completions", http.StatusMethodNotAllowed}, + } + for _, tc := range cases { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, nil)) + if w.Code != tc.want { + t.Errorf("status %d, want %d", w.Code, tc.want) + } + }) + } +} + +func TestLimitInflightCapsConcurrency(t *testing.T) { + const cap = 2 + var mu sync.Mutex + now, peak := 0, 0 + release := make(chan struct{}) + h := limitInflight(cap, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + now++ + if now > peak { + peak = now + } + mu.Unlock() + <-release + mu.Lock() + now-- + mu.Unlock() + })) + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)) + }() + } + // Let the first wave arrive, then drain. The assertion is the peak, and a + // peak that never reached the cap still cannot exceed it. + close(release) + wg.Wait() + if peak > cap { + t.Errorf("%d requests in flight at once, cap is %d", peak, cap) + } +} + +func TestLoopbackListen(t *testing.T) { + cases := map[string]bool{ + ":8080": false, // the shipped default, and the whole LAN + "0.0.0.0:8080": false, + "[::]:8080": false, + "192.168.1.105:8080": false, + "127.0.0.1:8080": true, + "[::1]:8080": true, + "localhost:8080": true, + } + for addr, want := range cases { + if got := loopbackListen(addr); got != want { + t.Errorf("loopbackListen(%q) = %v, want %v", addr, got, want) + } + } +} + +func TestReadToken(t *testing.T) { + dir := t.TempDir() + good := filepath.Join(dir, "tok") + if err := os.WriteFile(good, []byte(" abc123\n"), 0o600); err != nil { + t.Fatal(err) + } + got, err := readToken(good) + if err != nil || got != "abc123" { + t.Errorf("readToken = %q, %v; want abc123", got, err) + } + + blank := filepath.Join(dir, "blank") + if err := os.WriteFile(blank, []byte("\n\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := readToken(blank); err == nil { + t.Error("an empty token file is not a token") + } + if _, err := readToken(filepath.Join(dir, "absent")); err == nil { + t.Error("a missing token file is not a token") + } +} diff --git a/cmd/mavgpud/gpu_test.go b/cmd/mavgpud/gpu_test.go index 52dc07d..8e0a8e8 100644 --- a/cmd/mavgpud/gpu_test.go +++ b/cmd/mavgpud/gpu_test.go @@ -104,9 +104,11 @@ func TestHealthAndProxyRefuseWhenNotReady(t *testing.T) { s := &supervisor{run: newRunner("fake", "/bin/true", nil, "")} h := s.handler(mustURL(t, "http://127.0.0.1:1")) - for _, path := range []string{"/health", "/v1/chat/completions"} { + // The completion is a POST because the allowlist is in front of the + // readiness check now, and it answers 405 to a method it never serves. + for path, method := range map[string]string{"/health": http.MethodGet, "/v1/chat/completions": http.MethodPost} { w := httptest.NewRecorder() - h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + h.ServeHTTP(w, httptest.NewRequest(method, path, nil)) if w.Code != http.StatusServiceUnavailable { t.Errorf("%s with no model: got %d, want 503", path, w.Code) }