Redact the AI key from model lists and fit the longest prompt
CI / ci (push) Successful in 2m6s
CI / ci (pull_request) Successful in 2m7s

GET /settings/ai/models now removes the stored API key from every model
ID and display name the provider returns, so a provider that echoes the
key in a successful list cannot disclose it.

PATCH /settings/ai gets its own body limit, sized for 8,000 characters at
12 bytes of JSON each (a \u-escaped surrogate pair) plus the other
fields. A valid prompt of three- or four-byte characters was refused by
the shared 16 KiB limit; the 8,000-character rule stays the only limit
on the prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 09:04:16 -04:00
co-authored by Claude Opus 5.5
parent 94632e0950
commit 799e7b62d0
6 changed files with 41 additions and 10 deletions
+20 -3
View File
@@ -9,6 +9,7 @@ import (
"sync/atomic"
"testing"
"testing/fstest"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
)
@@ -38,7 +39,8 @@ func newAIRouter(t *testing.T) http.Handler {
// newModelsServer is an OpenAI-compatible server whose GET /models accepts
// only testAIKey. While echo is set it answers 500 with a body quoting the
// Authorization header it received, so a response that passed the vendor
// body through unredacted would leak the key.
// body through unredacted would leak the key. Its model list holds a model
// whose ID and name quote the key.
func newModelsServer(t *testing.T, echo *atomic.Bool) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -53,7 +55,8 @@ func newModelsServer(t *testing.T, echo *atomic.Bool) *httptest.Server {
w.WriteHeader(http.StatusUnauthorized)
io.WriteString(w, `{"error":{"message":"invalid key `+auth+`"}}`)
default:
io.WriteString(w, `{"data":[{"id":"gpt-test"},{"id":"gpt-test-mini","name":"GPT Test Mini"}]}`)
io.WriteString(w, `{"data":[{"id":"gpt-test"},{"id":"gpt-test-mini","name":"GPT Test Mini"},`+
`{"id":"gpt-`+testAIKey+`","name":"Leaky `+testAIKey+`"}]}`)
}
}))
t.Cleanup(srv.Close)
@@ -117,6 +120,8 @@ func TestAISettingsAPI(t *testing.T) {
`{"model":""}`,
`{"model":null}`,
`{"system_prompt":"` + strings.Repeat("é", 8001) + `"}`,
`{"system_prompt":"` + strings.Repeat("€", 8001) + `"}`,
`{"system_prompt":"` + strings.Repeat("a", maxAISettingsBody) + `"}`,
`{"api_key":"` + testAIKey + `"}`,
} {
if rec, body := call(http.MethodPatch, aiPath, bad); rec.Code != http.StatusBadRequest {
@@ -124,6 +129,15 @@ func TestAISettingsAPI(t *testing.T) {
}
}
// The longest prompt fits the body limit however its 8,000 characters
// are written: three-byte, four-byte, or as \u-escaped surrogate pairs.
for _, char := range []string{"€", "🍲", `\ud83c\udf72`} {
rec, body := call(http.MethodPatch, aiPath, `{"system_prompt":"`+strings.Repeat(char, 8000)+`"}`)
if prompt, _ := body["system_prompt"].(string); rec.Code != http.StatusOK || utf8.RuneCountInString(prompt) != 8000 {
t.Errorf("PATCH 8,000 × %s = %d, %d characters", char, rec.Code, utf8.RuneCountInString(prompt))
}
}
rec, body = call(http.MethodPatch, aiPath, `{"provider":"openai_compatible","base_url":"`+vendor.URL+`/v1","model":"gpt-test","temperature":0.3,"system_prompt":"`+strings.Repeat("é", 8000)+`"}`)
if rec.Code != http.StatusOK || body["provider"] != "openai_compatible" || body["base_url"] != vendor.URL+"/v1" ||
body["model"] != "gpt-test" || body["temperature"] != 0.3 {
@@ -178,12 +192,15 @@ func TestAISettingsAPI(t *testing.T) {
rec, body = call(http.MethodGet, aiModelsPath, "")
models, _ := body["models"].([]any)
if rec.Code != http.StatusOK || len(models) != 2 {
if rec.Code != http.StatusOK || len(models) != 3 {
t.Fatalf("GET models = %d %v", rec.Code, body)
}
if m := models[1].(map[string]any); m["id"] != "gpt-test-mini" || m["display_name"] != "GPT Test Mini" {
t.Errorf("GET models[1] = %v", m)
}
if m := models[2].(map[string]any); m["id"] != "gpt-[REDACTED]" || m["display_name"] != "Leaky [REDACTED]" {
t.Errorf("GET models[2] = %v, want the key redacted", m)
}
rec, body = call(http.MethodPatch, aiPath, `{"system_prompt":"Be brief."}`)
if rec.Code != http.StatusOK || body["system_prompt"] != "Be brief." || body["key_verified_at"] == nil {