Never insert a redaction marker that itself holds the API key
CI / ci (push) Successful in 1m29s
CI / ci (pull_request) Successful in 1m28s

llm.Redact is now the single redaction rule for ProviderError bodies and
the openai backstop. Each occurrence of the key becomes "[REDACTED]" only
when the result then holds no key; otherwise occurrences are deleted
until none remains, which also covers a marker that joins with its
neighbours to form the key. NewProviderError redacts again after
truncation so the "…" mark cannot complete the key.

The openai adapter drops its own redactKey and the withheld-text fallback,
which itself could hold a short key. redactedError.As now refuses a match
whose text holds the key, so errors.As cannot reach an unredacted error.

Tests cover the key "REDACTED" in a 500 body (ProviderError.Body and
Error after errors.As) and the key "A" in a malformed JSON body (every
unwrapped layer and every errors.As result).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 07:32:34 -04:00
co-authored by Claude Opus 5.5
parent cceeafd200
commit 68391d14c8
4 changed files with 158 additions and 33 deletions
+73 -1
View File
@@ -803,6 +803,12 @@ func assertNoKey(t *testing.T, err error, key string) {
// keyServer replies to every request with status and body.
func keyServer(t *testing.T, status int, body string) *Provider {
t.Helper()
return keyServerWithKey(t, headerNameKey, status, body)
}
// keyServerWithKey is keyServer for a provider configured with key.
func keyServerWithKey(t *testing.T, key string, status int, body string) *Provider {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -810,7 +816,7 @@ func keyServer(t *testing.T, status int, body string) *Provider {
io.WriteString(w, body)
}))
t.Cleanup(srv.Close)
p, err := New(Config{BaseURL: srv.URL + "/v1", APIKey: headerNameKey, Model: "test-model", StructuredOutput: PromptOnly})
p, err := New(Config{BaseURL: srv.URL + "/v1", APIKey: key, Model: "test-model", StructuredOutput: PromptOnly})
if err != nil {
t.Fatal(err)
}
@@ -970,3 +976,69 @@ type opaqueError struct{ err error }
func (e *opaqueError) Error() string { return "opaque" }
func (e *opaqueError) Unwrap() error { return e.err }
// assertChainNoExactKey fails if the text of err, of any error reached from
// it by unwrapping, or of a *llm.ProviderError found by errors.As holds key.
// It matches case-sensitively, so it suits keys too short for assertNoKey.
func assertChainNoExactKey(t *testing.T, err error, key string) {
t.Helper()
var walk func(error)
walk = func(e error) {
if e == nil {
return
}
if strings.Contains(e.Error(), key) {
t.Fatalf("error leaks %q: %q", key, e.Error())
}
switch u := e.(type) {
case interface{ Unwrap() error }:
walk(u.Unwrap())
case interface{ Unwrap() []error }:
for _, c := range u.Unwrap() {
walk(c)
}
}
}
walk(err)
var pe *llm.ProviderError
if errors.As(err, &pe) && (strings.Contains(pe.Body(), key) || strings.Contains(pe.Error(), key)) {
t.Fatalf("ProviderError leaks %q: body %q", key, pe.Body())
}
var syntax *json.SyntaxError
if errors.As(err, &syntax) && strings.Contains(syntax.Error(), key) {
t.Fatalf("errors.As reached a *json.SyntaxError leaking %q: %q", key, syntax.Error())
}
}
// TestProviderErrorBodyWithMarkerKeyNeverLeaksKey uses the redaction marker
// text as the API key, so replacing the key with the marker would keep it.
func TestProviderErrorBodyWithMarkerKeyNeverLeaksKey(t *testing.T) {
const key = "REDACTED"
p := keyServerWithKey(t, key, 500, `{"error":{"message":"rejected `+key+`: Bearer `+key+`"}}`)
_, err := p.Generate(context.Background(), schemaRequest())
var pe *llm.ProviderError
if !errors.Is(err, llm.ErrProvider) || !errors.As(err, &pe) || pe.Status() != 500 {
t.Fatalf("err = %v, want a 500 ProviderError", err)
}
if strings.Contains(pe.Body(), key) || strings.Contains(pe.Error(), key) {
t.Fatalf("ProviderError leaks the key: %q", pe.Error())
}
assertChainNoExactKey(t, err, key)
}
// TestGenerateMalformedJSONWithShortKeyNeverLeaksKey uses a one-letter key
// found in the redaction marker and in ordinary error prose.
func TestGenerateMalformedJSONWithShortKeyNeverLeaksKey(t *testing.T) {
const key = "A"
p := keyServerWithKey(t, key, 200, `{"choices": A Bearer A}`)
_, err := p.Generate(context.Background(), schemaRequest())
if err == nil {
t.Fatal("Generate accepted a malformed JSON body")
}
assertChainNoExactKey(t, err, key)
for layer := err; layer != nil; layer = errors.Unwrap(layer) {
if strings.Contains(layer.Error(), key) {
t.Fatalf("layer leaks %q: %q", key, layer.Error())
}
}
}