mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
b7c2c59931
Previously, Anthropic's per-modality, Priority Tier, and fast-mode rate-limit headers (`Anthropic-Ratelimit-Input-Tokens-*`, `Anthropic-Ratelimit-Output-Tokens-*`, `Anthropic-Priority-Input-Tokens-*`, `Anthropic-Priority-Output-Tokens-*`, `Anthropic-Fast-Input-Tokens-*`, and `Anthropic-Fast-Output-Tokens-*`) were shown as `[REDACTED]` in the Debug panel because they contain `"token"` in the name and fell through the generic credential filter. Add them to the allowlist in `coderd/x/chatd/chatdebug/redaction.go` alongside the existing `Anthropic-Ratelimit-Tokens-*` entries so the limits/remaining/reset values surface in the raw response view.
358 lines
13 KiB
Go
358 lines
13 KiB
Go
package chatdebug_test
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
|
|
)
|
|
|
|
func TestRedactHeaders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("nil input", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
require.Nil(t, chatdebug.RedactHeaders(nil))
|
|
})
|
|
|
|
t.Run("empty header", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
redacted := chatdebug.RedactHeaders(http.Header{})
|
|
require.NotNil(t, redacted)
|
|
require.Empty(t, redacted)
|
|
})
|
|
|
|
t.Run("authorization redacted and others preserved", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
headers := http.Header{
|
|
"Authorization": {"Bearer secret-token"},
|
|
"Accept": {"application/json"},
|
|
}
|
|
|
|
redacted := chatdebug.RedactHeaders(headers)
|
|
require.Equal(t, chatdebug.RedactedValue, redacted["Authorization"])
|
|
require.Equal(t, "application/json", redacted["Accept"])
|
|
})
|
|
|
|
t.Run("multi-value headers are flattened", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
headers := http.Header{
|
|
"Accept": {"application/json", "text/plain"},
|
|
}
|
|
|
|
redacted := chatdebug.RedactHeaders(headers)
|
|
require.Equal(t, "application/json, text/plain", redacted["Accept"])
|
|
})
|
|
|
|
t.Run("header name matching is case insensitive", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
lowerAuthorization := "authorization"
|
|
upperAuthorization := "AUTHORIZATION"
|
|
headers := http.Header{
|
|
lowerAuthorization: {"lower"},
|
|
upperAuthorization: {"upper"},
|
|
}
|
|
|
|
redacted := chatdebug.RedactHeaders(headers)
|
|
require.Equal(t, chatdebug.RedactedValue, redacted[lowerAuthorization])
|
|
require.Equal(t, chatdebug.RedactedValue, redacted[upperAuthorization])
|
|
})
|
|
|
|
t.Run("token and secret substrings are redacted", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
traceHeader := "X-Trace-ID"
|
|
headers := http.Header{
|
|
"X-Auth-Token": {"abc"},
|
|
"X-Custom-Secret": {"def"},
|
|
"X-Bearer": {"ghi"},
|
|
traceHeader: {"trace"},
|
|
}
|
|
|
|
redacted := chatdebug.RedactHeaders(headers)
|
|
require.Equal(t, chatdebug.RedactedValue, redacted["X-Auth-Token"])
|
|
require.Equal(t, chatdebug.RedactedValue, redacted["X-Custom-Secret"])
|
|
require.Equal(t, chatdebug.RedactedValue, redacted["X-Bearer"])
|
|
require.Equal(t, "trace", redacted[traceHeader])
|
|
})
|
|
|
|
t.Run("known safe rate limit headers containing token are not redacted", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
headers := http.Header{
|
|
"Anthropic-Ratelimit-Tokens-Limit": {"1000000"},
|
|
"Anthropic-Ratelimit-Tokens-Remaining": {"999000"},
|
|
"Anthropic-Ratelimit-Tokens-Reset": {"2026-03-31T08:55:26Z"},
|
|
"Anthropic-Ratelimit-Input-Tokens-Limit": {"200000"},
|
|
"Anthropic-Ratelimit-Input-Tokens-Remaining": {"199000"},
|
|
"Anthropic-Ratelimit-Input-Tokens-Reset": {"2026-03-31T08:55:26Z"},
|
|
"Anthropic-Ratelimit-Output-Tokens-Limit": {"80000"},
|
|
"Anthropic-Ratelimit-Output-Tokens-Remaining": {"79500"},
|
|
"Anthropic-Ratelimit-Output-Tokens-Reset": {"2026-03-31T08:55:26Z"},
|
|
"Anthropic-Priority-Input-Tokens-Limit": {"10000"},
|
|
"Anthropic-Priority-Input-Tokens-Remaining": {"9618"},
|
|
"Anthropic-Priority-Input-Tokens-Reset": {"2026-03-31T08:55:26Z"},
|
|
"Anthropic-Priority-Output-Tokens-Limit": {"10000"},
|
|
"Anthropic-Priority-Output-Tokens-Remaining": {"6000"},
|
|
"Anthropic-Priority-Output-Tokens-Reset": {"2026-03-31T08:55:26Z"},
|
|
"Anthropic-Fast-Input-Tokens-Limit": {"50000"},
|
|
"Anthropic-Fast-Input-Tokens-Remaining": {"49000"},
|
|
"Anthropic-Fast-Input-Tokens-Reset": {"2026-03-31T08:55:26Z"},
|
|
"Anthropic-Fast-Output-Tokens-Limit": {"25000"},
|
|
"Anthropic-Fast-Output-Tokens-Remaining": {"24000"},
|
|
"Anthropic-Fast-Output-Tokens-Reset": {"2026-03-31T08:55:26Z"},
|
|
"X-RateLimit-Limit-Tokens": {"120000"},
|
|
"X-RateLimit-Remaining-Tokens": {"119500"},
|
|
"X-RateLimit-Reset-Tokens": {"12ms"},
|
|
}
|
|
|
|
redacted := chatdebug.RedactHeaders(headers)
|
|
require.Equal(t, "1000000", redacted["Anthropic-Ratelimit-Tokens-Limit"])
|
|
require.Equal(t, "999000", redacted["Anthropic-Ratelimit-Tokens-Remaining"])
|
|
require.Equal(t, "2026-03-31T08:55:26Z", redacted["Anthropic-Ratelimit-Tokens-Reset"])
|
|
require.Equal(t, "200000", redacted["Anthropic-Ratelimit-Input-Tokens-Limit"])
|
|
require.Equal(t, "199000", redacted["Anthropic-Ratelimit-Input-Tokens-Remaining"])
|
|
require.Equal(t, "2026-03-31T08:55:26Z", redacted["Anthropic-Ratelimit-Input-Tokens-Reset"])
|
|
require.Equal(t, "80000", redacted["Anthropic-Ratelimit-Output-Tokens-Limit"])
|
|
require.Equal(t, "79500", redacted["Anthropic-Ratelimit-Output-Tokens-Remaining"])
|
|
require.Equal(t, "2026-03-31T08:55:26Z", redacted["Anthropic-Ratelimit-Output-Tokens-Reset"])
|
|
require.Equal(t, "10000", redacted["Anthropic-Priority-Input-Tokens-Limit"])
|
|
require.Equal(t, "9618", redacted["Anthropic-Priority-Input-Tokens-Remaining"])
|
|
require.Equal(t, "2026-03-31T08:55:26Z", redacted["Anthropic-Priority-Input-Tokens-Reset"])
|
|
require.Equal(t, "10000", redacted["Anthropic-Priority-Output-Tokens-Limit"])
|
|
require.Equal(t, "6000", redacted["Anthropic-Priority-Output-Tokens-Remaining"])
|
|
require.Equal(t, "2026-03-31T08:55:26Z", redacted["Anthropic-Priority-Output-Tokens-Reset"])
|
|
require.Equal(t, "50000", redacted["Anthropic-Fast-Input-Tokens-Limit"])
|
|
require.Equal(t, "49000", redacted["Anthropic-Fast-Input-Tokens-Remaining"])
|
|
require.Equal(t, "2026-03-31T08:55:26Z", redacted["Anthropic-Fast-Input-Tokens-Reset"])
|
|
require.Equal(t, "25000", redacted["Anthropic-Fast-Output-Tokens-Limit"])
|
|
require.Equal(t, "24000", redacted["Anthropic-Fast-Output-Tokens-Remaining"])
|
|
require.Equal(t, "2026-03-31T08:55:26Z", redacted["Anthropic-Fast-Output-Tokens-Reset"])
|
|
require.Equal(t, "120000", redacted["X-RateLimit-Limit-Tokens"])
|
|
require.Equal(t, "119500", redacted["X-RateLimit-Remaining-Tokens"])
|
|
require.Equal(t, "12ms", redacted["X-RateLimit-Reset-Tokens"])
|
|
})
|
|
|
|
t.Run("non-standard headers with api-key pattern are redacted", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
headers := http.Header{
|
|
"X-Custom-Api-Key": {"secret-key"},
|
|
"X-Custom-Secret": {"secret-val"},
|
|
"X-Custom-Session-Token": {"session-id"},
|
|
}
|
|
|
|
redacted := chatdebug.RedactHeaders(headers)
|
|
require.Equal(t, chatdebug.RedactedValue, redacted["X-Custom-Api-Key"])
|
|
require.Equal(t, chatdebug.RedactedValue, redacted["X-Custom-Secret"])
|
|
require.Equal(t, chatdebug.RedactedValue, redacted["X-Custom-Session-Token"])
|
|
})
|
|
|
|
t.Run("rate limit headers with token in name are preserved", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Rate-limit headers containing "token" should NOT be redacted
|
|
// because they carry usage/limit counts, not credentials.
|
|
headers := http.Header{
|
|
"X-Ratelimit-Limit-Tokens": {"1000000"},
|
|
"X-Ratelimit-Remaining-Tokens": {"999000"},
|
|
}
|
|
|
|
redacted := chatdebug.RedactHeaders(headers)
|
|
require.Equal(t, "1000000", redacted["X-Ratelimit-Limit-Tokens"])
|
|
require.Equal(t, "999000", redacted["X-Ratelimit-Remaining-Tokens"])
|
|
})
|
|
|
|
t.Run("original header is not modified", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
headers := http.Header{
|
|
"Authorization": {"Bearer keep-me"},
|
|
"X-Test": {"value"},
|
|
}
|
|
|
|
redacted := chatdebug.RedactHeaders(headers)
|
|
redacted["X-Test"] = "changed"
|
|
|
|
require.Equal(t, []string{"Bearer keep-me"}, headers["Authorization"])
|
|
require.Equal(t, []string{"value"}, headers["X-Test"])
|
|
require.Equal(t, chatdebug.RedactedValue, redacted["Authorization"])
|
|
})
|
|
t.Run("api-key header variants are redacted", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
headers := http.Header{
|
|
"X-Goog-Api-Key": {"secret"},
|
|
"X-Api_Key": {"other-secret"},
|
|
"X-Safe": {"ok"},
|
|
}
|
|
|
|
redacted := chatdebug.RedactHeaders(headers)
|
|
require.Equal(t, chatdebug.RedactedValue, redacted["X-Goog-Api-Key"])
|
|
require.Equal(t, chatdebug.RedactedValue, redacted["X-Api_Key"])
|
|
require.Equal(t, "ok", redacted["X-Safe"])
|
|
})
|
|
|
|
t.Run("plain token headers are redacted", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Headers like "Token" or "X-Token" should be redacted
|
|
// even without auth/session/access qualifiers.
|
|
headers := http.Header{
|
|
"Token": {"my-secret-token"},
|
|
"X-Token": {"another-secret"},
|
|
"X-Safe": {"ok"},
|
|
}
|
|
|
|
redacted := chatdebug.RedactHeaders(headers)
|
|
require.Equal(t, chatdebug.RedactedValue, redacted["Token"])
|
|
require.Equal(t, chatdebug.RedactedValue, redacted["X-Token"])
|
|
require.Equal(t, "ok", redacted["X-Safe"])
|
|
})
|
|
}
|
|
|
|
func TestRedactJSONSecrets(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("redacts top level secret fields", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := []byte(`{"api_key":"abc","token":"def","password":"ghi","safe":"ok"}`)
|
|
redacted := chatdebug.RedactJSONSecrets(input)
|
|
require.JSONEq(t, `{"api_key":"[REDACTED]","token":"[REDACTED]","password":"[REDACTED]","safe":"ok"}`, string(redacted))
|
|
})
|
|
|
|
t.Run("redacts security_token exact key", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := []byte(`{"security_token":"s3cret","securityToken":"tok","safe":"ok"}`)
|
|
redacted := chatdebug.RedactJSONSecrets(input)
|
|
require.JSONEq(t, `{"security_token":"[REDACTED]","securityToken":"[REDACTED]","safe":"ok"}`, string(redacted))
|
|
})
|
|
|
|
t.Run("preserves LLM token usage fields", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := []byte(`{"input_tokens":100,"output_tokens":50,"prompt_tokens":80,"completion_tokens":20,"reasoning_tokens":10,"cache_creation_input_tokens":5,"cache_read_input_tokens":3,"total_tokens":150,"max_tokens":4096,"max_output_tokens":2048}`)
|
|
redacted := chatdebug.RedactJSONSecrets(input)
|
|
// All usage/limit fields should be preserved, not redacted.
|
|
require.Equal(t, input, redacted)
|
|
})
|
|
|
|
t.Run("redacts nested objects", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := []byte(`{"outer":{"nested_secret":"abc","safe":1},"keep":true}`)
|
|
redacted := chatdebug.RedactJSONSecrets(input)
|
|
require.JSONEq(t, `{"outer":{"nested_secret":"[REDACTED]","safe":1},"keep":true}`, string(redacted))
|
|
})
|
|
|
|
t.Run("redacts arrays of objects", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := []byte(`[{"token":"abc"},{"value":1,"credentials":{"access_key":"def"}}]`)
|
|
redacted := chatdebug.RedactJSONSecrets(input)
|
|
require.JSONEq(t, `[{"token":"[REDACTED]"},{"value":1,"credentials":"[REDACTED]"}]`, string(redacted))
|
|
})
|
|
|
|
t.Run("concatenated JSON is replaced with diagnostic", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := []byte(`{"token":"abc"}{"safe":"ok"}`)
|
|
result := chatdebug.RedactJSONSecrets(input)
|
|
require.Contains(t, string(result), "extra JSON values")
|
|
})
|
|
|
|
t.Run("non JSON input is replaced with diagnostic", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := []byte("not json")
|
|
result := chatdebug.RedactJSONSecrets(input)
|
|
require.Contains(t, string(result), "not valid JSON")
|
|
})
|
|
|
|
t.Run("empty input is unchanged", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := []byte{}
|
|
require.Equal(t, input, chatdebug.RedactJSONSecrets(input))
|
|
})
|
|
|
|
t.Run("JSON without sensitive keys is unchanged", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := []byte(`{"safe":"ok","nested":{"value":1}}`)
|
|
require.Equal(t, input, chatdebug.RedactJSONSecrets(input))
|
|
})
|
|
|
|
t.Run("key matching is case insensitive", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
input := []byte(`{"API_KEY":"abc","Token":"def","PASSWORD":"ghi"}`)
|
|
redacted := chatdebug.RedactJSONSecrets(input)
|
|
require.JSONEq(t, `{"API_KEY":"[REDACTED]","Token":"[REDACTED]","PASSWORD":"[REDACTED]"}`, string(redacted))
|
|
})
|
|
|
|
t.Run("camelCase token field names are redacted", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Providers may use camelCase (e.g. accessToken, refreshToken).
|
|
// These should be redacted even though they don't match the
|
|
// snake_case originals exactly.
|
|
input := []byte(`{"accessToken":"abc","refreshToken":"def","authToken":"ghi","input_tokens":100,"output_tokens":50}`)
|
|
redacted := chatdebug.RedactJSONSecrets(input)
|
|
require.JSONEq(t, `{"accessToken":"[REDACTED]","refreshToken":"[REDACTED]","authToken":"[REDACTED]","input_tokens":100,"output_tokens":50}`, string(redacted))
|
|
})
|
|
}
|
|
|
|
func TestRedactNDJSONSecrets(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("empty input", func(t *testing.T) {
|
|
t.Parallel()
|
|
require.Empty(t, chatdebug.RedactNDJSONSecrets(nil))
|
|
require.Empty(t, chatdebug.RedactNDJSONSecrets([]byte{}))
|
|
})
|
|
|
|
t.Run("redacts secrets in each line", func(t *testing.T) {
|
|
t.Parallel()
|
|
input := []byte("{\"api_key\":\"sk-123\",\"safe\":\"ok\"}\n{\"token\":\"tok-456\",\"data\":\"value\"}\n")
|
|
redacted := chatdebug.RedactNDJSONSecrets(input)
|
|
lines := strings.Split(string(redacted), "\n")
|
|
require.JSONEq(t, `{"api_key":"[REDACTED]","safe":"ok"}`, lines[0])
|
|
require.JSONEq(t, `{"token":"[REDACTED]","data":"value"}`, lines[1])
|
|
})
|
|
|
|
t.Run("preserves lines without secrets", func(t *testing.T) {
|
|
t.Parallel()
|
|
input := []byte("{\"safe\":\"ok\"}\n{\"data\":\"value\"}\n")
|
|
redacted := chatdebug.RedactNDJSONSecrets(input)
|
|
require.Equal(t, string(input), string(redacted))
|
|
})
|
|
|
|
t.Run("handles malformed lines with fail-closed", func(t *testing.T) {
|
|
t.Parallel()
|
|
input := []byte("{\"safe\":\"ok\"}\nnot-json\n{\"token\":\"secret\"}\n")
|
|
redacted := chatdebug.RedactNDJSONSecrets(input)
|
|
lines := strings.Split(string(redacted), "\n")
|
|
require.JSONEq(t, `{"safe":"ok"}`, lines[0])
|
|
require.Contains(t, lines[1], "not valid JSON")
|
|
require.JSONEq(t, `{"token":"[REDACTED]"}`, lines[2])
|
|
})
|
|
|
|
t.Run("handles single line without trailing newline", func(t *testing.T) {
|
|
t.Parallel()
|
|
input := []byte(`{"api_key":"secret","value":"ok"}`)
|
|
redacted := chatdebug.RedactNDJSONSecrets(input)
|
|
require.JSONEq(t, `{"api_key":"[REDACTED]","value":"ok"}`, string(redacted))
|
|
})
|
|
}
|