Skip to content

Commit 094b8bc

Browse files
authored
fix(workbuddy): sanitize Claude Code billing attribution headers (#482)
Strip x-anthropic-billing-header and x-anthropic-billing-hdr from the existing WorkBuddy WAF request-body sanitizer (hub #855 / v2.0.8) so these reserved keys are not sent upstream as-is (400 / 11128). A system message that is only that header is dropped, and a leading system prompt is restored if needed to avoid empty content (11151). Co-authored-by: caigee-cmd <210803857+caigee-cmd@users.noreply.github.com>
1 parent 2c9c9da commit 094b8bc

3 files changed

Lines changed: 218 additions & 7 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
### English
2+
3+
- WorkBuddy request-body sanitization now strips Claude Code billing attribution headers (`x-anthropic-billing-header` and `x-anthropic-billing-hdr`, hub #855 / v2.0.8). Those reserved keys used to go upstream as-is and were rejected with 400 / 11128. A system message that is only that header is dropped, and a leading system prompt is restored if needed so the empty text does not trigger 400 / 11151.
4+
5+
### 中文
6+
7+
- WorkBuddy 请求体清洗现在会去掉 Claude Code 的计费归因头(`x-anthropic-billing-header` 与 `x-anthropic-billing-hdr`,对齐 hub #855 / v2.0.8)。这两个保留键原先会原样出站,上游直接回 400 / 11128。若整条 system 消息只剩该头,会丢弃该消息并在需要时补回一条 leading system,避免空文本触发 400 / 11151。

‎internal/providers/workbuddy/payload.go‎

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,18 @@ var (
368368

369369
wafBrandRe = regexp.MustCompile(`(?i)DeepSeek[ \t]+Harness`)
370370
wafBrandReplacement = "DeepSeek"
371+
372+
// Claude Code 2.1.37+ injects a billing attribution header as the first
373+
// system text block (hub #855 / v2.0.8). Upstream treats both spellings
374+
// as reserved keywords and answers 400 / 11128 if they appear as-is.
375+
// The full name is not a substring match of the "hdr" abbreviation
376+
// (`...-hdr` then sees `er:`), so both spellings are listed explicitly.
377+
// Line deletion also removes trailing parameters such as `cch=` that
378+
// do not start with the `cc_` prefix used by the leftover-KV cleaner.
379+
wafBillingHdrLineRe = regexp.MustCompile(`(?im)^[ \t]*x-anthropic-billing-(?:header|hdr)[ \t]*:[^\r\n]*(?:\r?\n|$)`)
380+
wafBillingHdrRe = regexp.MustCompile(`(?i)x-anthropic-billing-(?:header|hdr)\s*:\s*[^;\r\n]*(?:;\s*)?`)
381+
wafBillingHdrBareRe = regexp.MustCompile(`(?i)x-anthropic-billing-(?:header|hdr)`)
382+
wafBillingHdrKVRe = regexp.MustCompile(`(?i)\bcc_[a-z0-9_]+=[^;\r\n]*;?\s*`)
371383
)
372384

373385
// rewriteIdentitySentences replaces confirmed self-intro sentences. Sentence
@@ -405,9 +417,12 @@ func rewriteIdentitySentences(text string) string {
405417

406418
// sanitizeWAFKeywords rewrites known client fingerprint phrases that trigger
407419
// upstream WorkBuddy WAF rules (code 11128 / 11-128: "Illegal API invocation
408-
// from an unapproved channel"). Literal Main-branch rewrites apply to every
409-
// role; identity / DSH GUI / fileref / brand rules follow upstream and only touch
410-
// system, developer, and assistant.
420+
// from an unapproved channel"). Literal Main-branch rewrites and Claude Code
421+
// billing attribution headers apply to every role; identity / DSH GUI /
422+
// fileref / brand rules follow upstream and only touch system, developer,
423+
// and assistant. A message that becomes empty after sanitization is dropped
424+
// (empty content is code 11151); ensureLeadingSystem then restores a
425+
// non-empty leading system prompt if needed.
411426
func sanitizeWAFKeywords(body map[string]any) {
412427
raw, ok := body["messages"]
413428
if !ok {
@@ -427,19 +442,33 @@ func sanitizeWAFKeywords(body map[string]any) {
427442
if content, ok := message["content"].(string); ok {
428443
message["content"] = sanitizeFingerprintText(content, identity)
429444
} else if parts, ok := message["content"].([]any); ok {
445+
keptParts := make([]any, 0, len(parts))
430446
for _, part := range parts {
431-
if partMap, ok := part.(map[string]any); ok {
432-
if text, ok := partMap["text"].(string); ok {
433-
partMap["text"] = sanitizeFingerprintText(text, identity)
447+
partMap, ok := part.(map[string]any)
448+
if !ok {
449+
keptParts = append(keptParts, part)
450+
continue
451+
}
452+
if text, ok := partMap["text"].(string); ok {
453+
cleaned := sanitizeFingerprintText(text, identity)
454+
partMap["text"] = cleaned
455+
if strings.TrimSpace(text) != "" && strings.TrimSpace(cleaned) == "" {
456+
if typ, _ := partMap["type"].(string); typ == "text" || typ == "" {
457+
continue
458+
}
434459
}
435460
}
461+
keptParts = append(keptParts, part)
436462
}
463+
message["content"] = keptParts
437464
}
438465
// reasoning_content is model scratch, not an identity surface.
439466
if reasoning, ok := message["reasoning_content"].(string); ok {
440467
message["reasoning_content"] = sanitizeFingerprintText(reasoning, false)
441468
}
442469
}
470+
normalizeEmptyMessageContent(body)
471+
ensureLeadingSystem(body)
443472
}
444473

445474
func sanitizeFingerprintText(text string, identity bool) string {
@@ -457,5 +486,31 @@ func sanitizeFingerprintText(text string, identity bool) string {
457486
text = strings.ReplaceAll(text, rule.pattern, rule.replacement)
458487
}
459488
}
460-
return text
489+
return sanitizeBillingHeaders(text)
490+
}
491+
492+
// containsBillingHeader reports either official spelling. The abbreviated
493+
// "hdr" form is not a substring of "header", so both needles are required.
494+
func containsBillingHeader(text string) bool {
495+
low := strings.ToLower(text)
496+
return strings.Contains(low, "x-anthropic-billing-header") ||
497+
strings.Contains(low, "x-anthropic-billing-hdr")
498+
}
499+
500+
func sanitizeBillingHeaders(text string) string {
501+
if !containsBillingHeader(text) {
502+
return text
503+
}
504+
text = wafBillingHdrLineRe.ReplaceAllString(text, "")
505+
text = wafBillingHdrRe.ReplaceAllString(text, "")
506+
if strings.Contains(strings.ToLower(text), "cc_") {
507+
for {
508+
next := wafBillingHdrKVRe.ReplaceAllString(text, "")
509+
if next == text {
510+
break
511+
}
512+
text = next
513+
}
514+
}
515+
return wafBillingHdrBareRe.ReplaceAllString(text, "anthropic-header")
461516
}

‎internal/providers/workbuddy/waf_fingerprint_test.go‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,152 @@ func TestSanitizeWAFDSHFilerefParagraph(t *testing.T) {
165165
t.Fatalf("tool must stay untouched: %q", got)
166166
}
167167
}
168+
169+
const billingHeaderFull = "x-anthropic-billing-header: cc_version=2.1.321.abc; " +
170+
"cc_entrypoint=cli; cch=00000;"
171+
const billingHeaderAbbrev = "x-anthropic-billing-hdr: cc_version=2.1.321.abc; " +
172+
"cc_entrypoint=cli; cch=00000;"
173+
174+
func TestSanitizeWAFBillingAttributionHeaders(t *testing.T) {
175+
if got := sanitizeFingerprintText(billingHeaderFull, true); got != "" {
176+
t.Fatalf("full header line = %q, want empty", got)
177+
}
178+
if got := sanitizeFingerprintText(billingHeaderAbbrev, true); got != "" {
179+
t.Fatalf("abbrev header line = %q, want empty", got)
180+
}
181+
182+
prompt := billingHeaderFull + "\nYou are a helpful AI assistant."
183+
if got := sanitizeFingerprintText(prompt, true); got != "You are a helpful AI assistant." {
184+
t.Fatalf("header before prompt = %q", got)
185+
}
186+
if got := sanitizeFingerprintText(billingHeaderAbbrev+"\nKeep this.", true); got != "Keep this." {
187+
t.Fatalf("abbrev header before prompt = %q", got)
188+
}
189+
190+
upper := strings.ToUpper(billingHeaderFull) + "\nKeep this."
191+
if got := sanitizeFingerprintText(upper, true); got != "Keep this." {
192+
t.Fatalf("case-insensitive = %q", got)
193+
}
194+
195+
if got := sanitizeFingerprintText("`x-anthropic-billing-header`", true); got != "`anthropic-header`" {
196+
t.Fatalf("bare full key = %q", got)
197+
}
198+
if got := sanitizeFingerprintText("`x-anthropic-billing-hdr`", false); got != "`anthropic-header`" {
199+
t.Fatalf("bare abbrev key = %q", got)
200+
}
201+
202+
// The longer spelling must be removed as a whole; matching only "...-hdr"
203+
// would leave a leading "er:" in the outbound text.
204+
if got := sanitizeFingerprintText(billingHeaderFull, false); strings.Contains(strings.ToLower(got), "er:") || strings.Contains(strings.ToLower(got), "x-anthropic-billing") {
205+
t.Fatalf("full header leaked after identity=false sanitize: %q", got)
206+
}
207+
208+
untouched := "Hello world untouched"
209+
if got := sanitizeFingerprintText(untouched, true); got != untouched {
210+
t.Fatalf("unrelated text rewritten: %q", got)
211+
}
212+
}
213+
214+
func TestSanitizeWAFBillingHeaderMessages(t *testing.T) {
215+
body := map[string]any{
216+
"messages": []any{
217+
map[string]any{"role": "system", "content": billingHeaderFull},
218+
map[string]any{"role": "system", "content": billingHeaderFull + "\nYou are a helpful AI assistant."},
219+
map[string]any{"role": "user", "content": "hi"},
220+
map[string]any{"role": "user", "content": billingHeaderAbbrev + "\nPlease review this."},
221+
map[string]any{"role": "assistant", "content": []any{
222+
map[string]any{"type": "text", "text": billingHeaderFull},
223+
map[string]any{"type": "text", "text": "Other fields stay."},
224+
}},
225+
},
226+
}
227+
sanitizeWAFKeywords(body)
228+
msgs := body["messages"].([]any)
229+
if len(msgs) != 4 {
230+
t.Fatalf("expected 4 messages after dropping header-only system, got %d: %#v", len(msgs), msgs)
231+
}
232+
233+
sys := msgs[0].(map[string]any)
234+
if sys["role"] != "system" || sys["content"] != "You are a helpful AI assistant." {
235+
t.Fatalf("remaining system = %#v", sys)
236+
}
237+
if strings.Contains(fmtContent(sys["content"]), "x-anthropic-billing") {
238+
t.Fatalf("system still contains billing header: %#v", sys)
239+
}
240+
241+
user := msgs[1].(map[string]any)
242+
if user["role"] != "user" || user["content"] != "hi" {
243+
t.Fatalf("untouched user changed: %#v", user)
244+
}
245+
246+
userHdr := msgs[2].(map[string]any)
247+
if userHdr["content"] != "Please review this." {
248+
t.Fatalf("user header not stripped: %#v", userHdr)
249+
}
250+
251+
asst := msgs[3].(map[string]any)
252+
parts, _ := asst["content"].([]any)
253+
if len(parts) != 1 {
254+
t.Fatalf("header-only text part should be dropped, got %#v", parts)
255+
}
256+
if parts[0].(map[string]any)["text"] != "Other fields stay." {
257+
t.Fatalf("remaining part = %#v", parts[0])
258+
}
259+
}
260+
261+
func TestPrepareBodySanitizesBillingHeaders(t *testing.T) {
262+
src, _ := json.Marshal(map[string]any{
263+
"model": "wb",
264+
"messages": []any{
265+
map[string]any{"role": "system", "content": billingHeaderFull},
266+
map[string]any{"role": "user", "content": "hi"},
267+
},
268+
})
269+
out := PrepareBody(src)
270+
var body map[string]any
271+
if err := json.Unmarshal(out, &body); err != nil {
272+
t.Fatal(err)
273+
}
274+
msgs := body["messages"].([]any)
275+
if len(msgs) != 2 {
276+
t.Fatalf("expected leading system + user, got %d: %#v", len(msgs), msgs)
277+
}
278+
sys := msgs[0].(map[string]any)
279+
if sys["role"] != "system" || sys["content"] != "You are a helpful assistant." {
280+
t.Fatalf("header-only system should be replaced with placeholder: %#v", sys)
281+
}
282+
if strings.Contains(string(out), "x-anthropic-billing-header") || strings.Contains(string(out), "x-anthropic-billing-hdr") {
283+
t.Fatalf("billing header leaked outbound: %s", out)
284+
}
285+
if got := msgs[1].(map[string]any)["content"]; got != "hi" {
286+
t.Fatalf("user changed: %q", got)
287+
}
288+
289+
withPrompt, _ := json.Marshal(map[string]any{
290+
"model": "wb",
291+
"extra": "keep-me",
292+
"messages": []any{
293+
map[string]any{"role": "system", "content": billingHeaderAbbrev + "\nYou are a helpful AI assistant."},
294+
map[string]any{"role": "user", "content": "hello world untouched"},
295+
},
296+
})
297+
out = PrepareBody(withPrompt)
298+
if err := json.Unmarshal(out, &body); err != nil {
299+
t.Fatal(err)
300+
}
301+
if body["extra"] != "keep-me" {
302+
t.Fatalf("unrelated field changed: %#v", body["extra"])
303+
}
304+
msgs = body["messages"].([]any)
305+
if got := msgs[0].(map[string]any)["content"]; got != "You are a helpful AI assistant." {
306+
t.Fatalf("prepared system = %q", got)
307+
}
308+
if got := msgs[1].(map[string]any)["content"]; got != "hello world untouched" {
309+
t.Fatalf("prepared user = %q", got)
310+
}
311+
}
312+
313+
func fmtContent(v any) string {
314+
s, _ := v.(string)
315+
return s
316+
}

0 commit comments

Comments
 (0)