mirror of
https://github.com/coder/coder.git
synced 2026-06-03 13:08:25 +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.
281 lines
8.2 KiB
Go
281 lines
8.2 KiB
Go
package chatdebug
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"golang.org/x/xerrors"
|
|
)
|
|
|
|
// RedactedValue replaces sensitive values in debug payloads.
|
|
const RedactedValue = "[REDACTED]"
|
|
|
|
var sensitiveHeaderNames = map[string]struct{}{
|
|
"authorization": {},
|
|
"x-api-key": {},
|
|
"api-key": {},
|
|
"proxy-authorization": {},
|
|
"cookie": {},
|
|
"set-cookie": {},
|
|
}
|
|
|
|
// sensitiveJSONKeyFragments triggers redaction for JSON keys containing
|
|
// these substrings. Notably, "token" is intentionally absent because it
|
|
// false-positively redacts LLM token-usage fields (input_tokens,
|
|
// output_tokens, prompt_tokens, completion_tokens, reasoning_tokens,
|
|
// cache_creation_input_tokens, cache_read_input_tokens, etc.). Auth-
|
|
// related token fields are caught by the exact-match set below.
|
|
var sensitiveJSONKeyFragments = []string{
|
|
"secret",
|
|
"password",
|
|
"authorization",
|
|
"credential",
|
|
}
|
|
|
|
// sensitiveJSONKeyExact matches auth-related token/key field names
|
|
// without false-positiving on LLM usage counters. Includes both
|
|
// snake_case originals and their camelCase-lowered equivalents
|
|
// (e.g. "accessToken" → "accesstoken") so that providers using
|
|
// either convention are caught.
|
|
var sensitiveJSONKeyExact = map[string]struct{}{
|
|
"token": {},
|
|
"access_token": {},
|
|
"accesstoken": {},
|
|
"refresh_token": {},
|
|
"refreshtoken": {},
|
|
"id_token": {},
|
|
"idtoken": {},
|
|
"api_token": {},
|
|
"apitoken": {},
|
|
"api_key": {},
|
|
"apikey": {},
|
|
"api-key": {},
|
|
"x-api-key": {},
|
|
"auth_token": {},
|
|
"authtoken": {},
|
|
"bearer_token": {},
|
|
"bearertoken": {},
|
|
"session_token": {},
|
|
"sessiontoken": {},
|
|
"security_token": {},
|
|
"securitytoken": {},
|
|
"private_key": {},
|
|
"privatekey": {},
|
|
"signing_key": {},
|
|
"signingkey": {},
|
|
"secret_key": {},
|
|
"secretkey": {},
|
|
}
|
|
|
|
// RedactHeaders returns a flattened copy of h with sensitive values redacted.
|
|
func RedactHeaders(h http.Header) map[string]string {
|
|
if h == nil {
|
|
return nil
|
|
}
|
|
|
|
redacted := make(map[string]string, len(h))
|
|
for name, values := range h {
|
|
if isSensitiveName(name) {
|
|
redacted[name] = RedactedValue
|
|
continue
|
|
}
|
|
redacted[name] = strings.Join(values, ", ")
|
|
}
|
|
return redacted
|
|
}
|
|
|
|
// RedactJSONSecrets redacts sensitive JSON values by key name. When
|
|
// the input is not valid JSON (truncated body, HTML error page, etc.)
|
|
// the raw bytes are replaced entirely with a diagnostic placeholder
|
|
// to avoid leaking credentials from malformed payloads.
|
|
func RedactJSONSecrets(data []byte) []byte {
|
|
if len(data) == 0 {
|
|
return data
|
|
}
|
|
|
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
decoder.UseNumber()
|
|
|
|
var value any
|
|
if err := decoder.Decode(&value); err != nil {
|
|
// Cannot parse: replace entirely to prevent credential leaks
|
|
// from non-JSON error responses (HTML pages, partial bodies).
|
|
return []byte(`{"error":"chatdebug: body is not valid JSON, redacted for safety"}`)
|
|
}
|
|
if err := consumeJSONEOF(decoder); err != nil {
|
|
return []byte(`{"error":"chatdebug: body contains extra JSON values, redacted for safety"}`)
|
|
}
|
|
|
|
redacted, changed := redactJSONValue(value)
|
|
if !changed {
|
|
return data
|
|
}
|
|
|
|
encoded, err := json.Marshal(redacted)
|
|
if err != nil {
|
|
return data
|
|
}
|
|
return encoded
|
|
}
|
|
|
|
// RedactNDJSONSecrets redacts sensitive values in newline-delimited
|
|
// JSON (NDJSON) payloads. Each non-empty line is treated as an
|
|
// independent JSON document and redacted individually. Lines that
|
|
// fail to parse are replaced with a diagnostic placeholder.
|
|
func RedactNDJSONSecrets(data []byte) []byte {
|
|
if len(data) == 0 {
|
|
return data
|
|
}
|
|
|
|
lines := bytes.Split(data, []byte("\n"))
|
|
changed := false
|
|
for i, line := range lines {
|
|
trimmed := bytes.TrimSpace(line)
|
|
if len(trimmed) == 0 {
|
|
continue
|
|
}
|
|
redacted := RedactJSONSecrets(trimmed)
|
|
if !bytes.Equal(redacted, trimmed) {
|
|
lines[i] = redacted
|
|
changed = true
|
|
}
|
|
}
|
|
if !changed {
|
|
return data
|
|
}
|
|
return bytes.Join(lines, []byte("\n"))
|
|
}
|
|
|
|
func consumeJSONEOF(decoder *json.Decoder) error {
|
|
var extra any
|
|
err := decoder.Decode(&extra)
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
if err == nil {
|
|
return xerrors.New("chatdebug: extra JSON values")
|
|
}
|
|
return err
|
|
}
|
|
|
|
// safeRateLimitHeaderNames lists rate-limit headers that contain
|
|
// "token" in the name but carry numeric usage counters, not
|
|
// credentials. They are checked in isSensitiveName before the
|
|
// generic "token" substring match so they pass through unredacted.
|
|
// Add new entries here when a provider introduces a rate-limit
|
|
// header family containing "token" (e.g. Anthropic's per-modality,
|
|
// Priority Tier, or fast-mode headers).
|
|
var safeRateLimitHeaderNames = map[string]struct{}{
|
|
"anthropic-ratelimit-requests-limit": {},
|
|
"anthropic-ratelimit-requests-remaining": {},
|
|
"anthropic-ratelimit-requests-reset": {},
|
|
"anthropic-ratelimit-tokens-limit": {},
|
|
"anthropic-ratelimit-tokens-remaining": {},
|
|
"anthropic-ratelimit-tokens-reset": {},
|
|
"anthropic-ratelimit-input-tokens-limit": {},
|
|
"anthropic-ratelimit-input-tokens-remaining": {},
|
|
"anthropic-ratelimit-input-tokens-reset": {},
|
|
"anthropic-ratelimit-output-tokens-limit": {},
|
|
"anthropic-ratelimit-output-tokens-remaining": {},
|
|
"anthropic-ratelimit-output-tokens-reset": {},
|
|
"anthropic-priority-input-tokens-limit": {},
|
|
"anthropic-priority-input-tokens-remaining": {},
|
|
"anthropic-priority-input-tokens-reset": {},
|
|
"anthropic-priority-output-tokens-limit": {},
|
|
"anthropic-priority-output-tokens-remaining": {},
|
|
"anthropic-priority-output-tokens-reset": {},
|
|
"anthropic-fast-input-tokens-limit": {},
|
|
"anthropic-fast-input-tokens-remaining": {},
|
|
"anthropic-fast-input-tokens-reset": {},
|
|
"anthropic-fast-output-tokens-limit": {},
|
|
"anthropic-fast-output-tokens-remaining": {},
|
|
"anthropic-fast-output-tokens-reset": {},
|
|
"x-ratelimit-limit-requests": {},
|
|
"x-ratelimit-limit-tokens": {},
|
|
"x-ratelimit-remaining-requests": {},
|
|
"x-ratelimit-remaining-tokens": {},
|
|
"x-ratelimit-reset-requests": {},
|
|
"x-ratelimit-reset-tokens": {},
|
|
}
|
|
|
|
// isSensitiveName reports whether a name (header or query parameter)
|
|
// looks like a credential-carrying key. Exact-match headers are
|
|
// checked first, then the rate-limit allowlist, then substring
|
|
// patterns for API keys and auth tokens.
|
|
func isSensitiveName(name string) bool {
|
|
lowerName := strings.ToLower(name)
|
|
if _, ok := sensitiveHeaderNames[lowerName]; ok {
|
|
return true
|
|
}
|
|
if _, ok := safeRateLimitHeaderNames[lowerName]; ok {
|
|
return false
|
|
}
|
|
if strings.Contains(lowerName, "api-key") ||
|
|
strings.Contains(lowerName, "api_key") ||
|
|
strings.Contains(lowerName, "apikey") {
|
|
return true
|
|
}
|
|
// Catch any header containing "token" (e.g. Token, X-Token,
|
|
// X-Auth-Token). Safe rate-limit headers like
|
|
// x-ratelimit-remaining-tokens are already allowlisted above
|
|
// and will not reach this point.
|
|
if strings.Contains(lowerName, "token") {
|
|
return true
|
|
}
|
|
return strings.Contains(lowerName, "secret") ||
|
|
strings.Contains(lowerName, "bearer")
|
|
}
|
|
|
|
func isSensitiveJSONKey(key string) bool {
|
|
lowerKey := strings.ToLower(key)
|
|
if _, ok := sensitiveJSONKeyExact[lowerKey]; ok {
|
|
return true
|
|
}
|
|
for _, fragment := range sensitiveJSONKeyFragments {
|
|
if strings.Contains(lowerKey, fragment) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func redactJSONValue(value any) (any, bool) {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
changed := false
|
|
for key, child := range typed {
|
|
if isSensitiveJSONKey(key) {
|
|
if current, ok := child.(string); ok && current == RedactedValue {
|
|
continue
|
|
}
|
|
typed[key] = RedactedValue
|
|
changed = true
|
|
continue
|
|
}
|
|
|
|
redactedChild, childChanged := redactJSONValue(child)
|
|
if childChanged {
|
|
typed[key] = redactedChild
|
|
changed = true
|
|
}
|
|
}
|
|
return typed, changed
|
|
case []any:
|
|
changed := false
|
|
for i, child := range typed {
|
|
redactedChild, childChanged := redactJSONValue(child)
|
|
if childChanged {
|
|
typed[i] = redactedChild
|
|
changed = true
|
|
}
|
|
}
|
|
return typed, changed
|
|
default:
|
|
return value, false
|
|
}
|
|
}
|