Keep the stored AI key on the endpoint it was stored for
CI / ci (push) Successful in 1m59s
CI / ci (pull_request) Successful in 1m59s

PATCH /settings/ai refused nothing when the base URL or provider changed
with a key stored, so a session could point base_url at another server
and have POST /settings/ai/verify send it the key. Both changes are now
refused while a key is stored: remove the key, change the endpoint, then
store the new key. A preset without a fixed base URL takes a key only
when its base URL passes secureurl.Check, so the key never crosses the
network in plain text.

Maestro task: 176b2a30-6e90-4339-b430-5b8967b2fd32

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 08:52:01 -04:00
co-authored by Claude Opus 5.5
parent 60a92ec78f
commit 94632e0950
4 changed files with 112 additions and 35 deletions
+10 -6
View File
@@ -194,15 +194,19 @@ func TestAISettingsAPI(t *testing.T) {
t.Errorf("POST reset-prompt = %d %v", rec.Code, body)
}
// Pointing the key at another server voids its verification.
rec, body = call(http.MethodPatch, aiPath, `{"base_url":"https://llm.example/v1"}`)
if rec.Code != http.StatusOK || body["key_verified_at"] != nil || body["api_key_last4"] != "7d21" {
t.Errorf("PATCH base_url = %d %v", rec.Code, body)
// The stored key is never pointed at another server or provider.
for _, patch := range []string{`{"base_url":"https://attacker.example/v1"}`, `{"provider":"openai","base_url":""}`} {
if rec, body := call(http.MethodPatch, aiPath, patch); rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_settings" {
t.Errorf("PATCH %s with a key stored = %d %v, want 400 invalid_settings", patch, rec.Code, body)
}
}
if rec, body := call(http.MethodPut, aiKeyPath, `{"api_key":""}`); rec.Code != http.StatusOK || body["api_key_last4"] != nil {
if rec, body := call(http.MethodPut, aiKeyPath, `{"api_key":""}`); rec.Code != http.StatusOK || body["api_key_last4"] != nil || body["key_verified_at"] != nil {
t.Errorf("PUT empty key = %d %v", rec.Code, body)
}
rec, body = call(http.MethodPatch, aiPath, `{"base_url":"https://llm.example/v1"}`)
if rec.Code != http.StatusOK || body["base_url"] != "https://llm.example/v1" {
t.Errorf("PATCH base_url without a key = %d %v", rec.Code, body)
}
if len(bodies) < 30 {
t.Fatalf("scanned only %d response bodies", len(bodies))