Redact every layer of a returned error chain, not just the top
CI / ci (push) Successful in 1m29s
CI / ci (pull_request) Successful in 1m28s

redactedError.Unwrap returned the original error, so errors.Unwrap could
reach an inner error whose text still held the API key. Unwrap now passes
each cause through redact, so every error reached by unwrapping has the
key removed, and errors with several causes are handled the same way.
redact also checks the whole chain, catching a layer that hides the key
from its own text but wraps an error that shows it. Is and As consult the
original chain, so sentinels and *llm.ProviderError still match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 07:26:37 -04:00
co-authored by Claude Opus 5.5
parent 2afd6a9338
commit cceeafd200
2 changed files with 146 additions and 14 deletions
+88 -2
View File
@@ -868,8 +868,8 @@ func TestProviderErrorBodyNeverLeaksKey(t *testing.T) {
func TestRedactKeepsChainAndHidesKey(t *testing.T) {
inner := fmt.Errorf("decode: body %q: %w", "Bearer "+testKey, llm.ErrBadOutput)
err := redact(inner, testKey)
assertNoSecret(t, err)
if !errors.Is(err, llm.ErrBadOutput) || errors.Unwrap(err) != inner {
assertChainNoKey(t, err, testKey)
if !errors.Is(err, llm.ErrBadOutput) {
t.Fatalf("redact broke the chain of %v", err)
}
if redact(inner, "") != inner {
@@ -884,3 +884,89 @@ func TestRedactKeepsChainAndHidesKey(t *testing.T) {
t.Fatalf("redact kept the key: %q", got)
}
}
// assertChainNoKey fails if err, or any error reached from it by unwrapping,
// has text holding key or "Bearer "+key.
func assertChainNoKey(t *testing.T, err error, key string) {
t.Helper()
if err == nil {
return
}
assertNoKey(t, err, key)
switch u := err.(type) {
case interface{ Unwrap() error }:
assertChainNoKey(t, u.Unwrap(), key)
case interface{ Unwrap() []error }:
for _, c := range u.Unwrap() {
assertChainNoKey(t, c, key)
}
}
}
// TestGenerateInvalidJSONWithKeyNeverLeaksKeyByUnwrapping sends an invalid
// JSON body holding the key and walks the returned chain with errors.Unwrap.
func TestGenerateInvalidJSONWithKeyNeverLeaksKeyByUnwrapping(t *testing.T) {
p := keyServer(t, 200, `{"choices": Bearer `+headerNameKey+` `+headerNameKey+`}`)
_, err := p.Generate(context.Background(), schemaRequest())
if err == nil {
t.Fatal("Generate accepted an invalid JSON body")
}
var syntax *json.SyntaxError
if !errors.As(err, &syntax) {
t.Fatalf("err = %v, want a *json.SyntaxError in the chain", err)
}
for layer := err; layer != nil; layer = errors.Unwrap(layer) {
assertNoKey(t, layer, headerNameKey)
}
}
// TestRedactMakesEveryUnwrappedLayerSafe covers chains whose inner layers
// hold the key, including a layer whose own text does not show it and a
// layer with several causes, and checks Is and As still match.
func TestRedactMakesEveryUnwrappedLayerSafe(t *testing.T) {
pe := llm.NewProviderError(500, []byte(`{"error":"down"}`), testKey)
hidden := &opaqueError{err: fmt.Errorf("body Bearer %s: %w", testKey, llm.ErrAuth)}
inner := fmt.Errorf("decode %s: %w", testKey, llm.ErrBadOutput)
joined := errors.Join(inner, pe, context.Canceled, fmt.Errorf("limit: %w", llm.ErrRateLimited))
cases := []error{
fmt.Errorf("openai: generate: %w", inner),
fmt.Errorf("openai: generate: %w", hidden),
fmt.Errorf("openai: generate: %w", joined),
fmt.Errorf("openai: %w and %w", inner, &transportFailure{detail: "slow", kind: llm.ErrTimeout}),
}
for _, c := range cases {
err := redact(c, testKey)
if err == c {
t.Fatalf("redact left %v unchanged", c)
}
assertChainNoKey(t, err, testKey)
for layer := err; layer != nil; layer = errors.Unwrap(layer) {
assertNoKey(t, layer, testKey)
}
}
err := redact(fmt.Errorf("openai: %w", joined), testKey)
var got *llm.ProviderError
if !errors.As(err, &got) || got != pe {
t.Fatalf("errors.As lost the ProviderError in %v", err)
}
for _, target := range []error{llm.ErrBadOutput, llm.ErrProvider, llm.ErrRateLimited, context.Canceled} {
if !errors.Is(err, target) {
t.Fatalf("errors.Is(%v, %v) = false", err, target)
}
}
err = redact(fmt.Errorf("openai: %w", hidden), testKey)
if !errors.Is(err, llm.ErrAuth) {
t.Fatalf("errors.Is lost ErrAuth in %v", err)
}
err = redact(fmt.Errorf("openai %s: %w", testKey, &transportFailure{detail: "slow", kind: llm.ErrTimeout}), testKey)
if !errors.Is(err, llm.ErrTimeout) {
t.Fatalf("errors.Is lost ErrTimeout in %v", err)
}
}
// opaqueError hides its cause's text but unwraps to it.
type opaqueError struct{ err error }
func (e *opaqueError) Error() string { return "opaque" }
func (e *opaqueError) Unwrap() error { return e.err }