Skip to content

Commit 004403b

Browse files
fix(tracing/anthropic): guard ContentBlockDelta / Stop indices from negatives
The upper-bound check on ContentBlockDelta/Stop was `idx < len(response.Content)`. In Go, that condition is trivially satisfied by negative ints, so a malformed or hostile upstream chunk with `Index: -1` slipped through and panicked on the `response.Content[idx]` access below. The panic surfaces inside the tracing pipeline (Anthropic messages recorder) and takes the request down when OpenInference tracing is enabled. Only ContentBlockStart had the non-negative guard. Extract the shared check into `isValidContentBlockIndex(idx)` and apply it to every SSE index path (Start / Delta / Stop). The upper bound (maxContentBlocks = 1000) is preserved so a giant Index can't force a giant allocation either. Regression test added: a chunk sequence with a valid Start followed by ContentBlockDelta{Index:-5} and ContentBlockStop{Index:-1} now completes without panic and produces the same content as if those events were absent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d230e3c commit 004403b

2 files changed

Lines changed: 72 additions & 18 deletions

File tree

internal/tracing/openinference/anthropic/messages.go

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,19 @@ func buildResponseAttributes(resp *anthropic.MessagesResponse, config *openinfer
233233
return attrs
234234
}
235235

236+
// maxContentBlocks caps how large a single Anthropic streaming message's content array can
237+
// grow inside the tracing span. Well-formed responses stay well below this; the cap exists so
238+
// a hostile / buggy upstream cannot force a giant allocation by sending an out-of-range index.
239+
const maxContentBlocks = 1000
240+
241+
// isValidContentBlockIndex applies the same non-negative / bounded check to every SSE index
242+
// path (ContentBlockStart / ContentBlockDelta / ContentBlockStop). Negative indices used to
243+
// slip through the delta/stop branches — the length check alone (`idx < len`) is satisfied by
244+
// negative values in Go, which then panic on the slice index below.
245+
func isValidContentBlockIndex(idx int) bool {
246+
return idx >= 0 && idx < maxContentBlocks
247+
}
248+
236249
// convertSSEToResponse converts a complete SSE stream to a single JSON-encoded
237250
// openai.ChatCompletionResponse. This will not serialize zero values including
238251
// fields whose values are zero or empty, or nested objects where all fields
@@ -270,9 +283,7 @@ func convertSSEToResponse(chunks []*anthropic.MessagesStreamChunk) *anthropic.Me
270283

271284
case event.ContentBlockStart != nil:
272285
idx := event.ContentBlockStart.Index
273-
// Guard against negative or unreasonably large indices from a hostile upstream.
274-
const maxContentBlocks = 1000
275-
if idx < 0 || idx >= maxContentBlocks {
286+
if !isValidContentBlockIndex(idx) {
276287
continue
277288
}
278289
// Grow slice if needed.
@@ -285,28 +296,35 @@ func convertSSEToResponse(chunks []*anthropic.MessagesStreamChunk) *anthropic.Me
285296

286297
case event.ContentBlockDelta != nil:
287298
idx := event.ContentBlockDelta.Index
288-
if idx < len(response.Content) {
289-
block := &response.Content[idx]
290-
delta := event.ContentBlockDelta.Delta
299+
// The upper-bound check (idx < len) already blocks out-of-range access, but a
300+
// negative index from a hostile / malformed upstream would satisfy `idx < len`
301+
// yet still panic on the slice index below. Reject negatives explicitly.
302+
if !isValidContentBlockIndex(idx) || idx >= len(response.Content) {
303+
continue
304+
}
305+
block := &response.Content[idx]
306+
delta := event.ContentBlockDelta.Delta
291307

292-
if block.Text != nil && delta.Text != "" {
293-
block.Text.Text += delta.Text
294-
}
295-
if block.Tool != nil && delta.PartialJSON != "" {
296-
toolInputs[idx] += delta.PartialJSON
308+
if block.Text != nil && delta.Text != "" {
309+
block.Text.Text += delta.Text
310+
}
311+
if block.Tool != nil && delta.PartialJSON != "" {
312+
toolInputs[idx] += delta.PartialJSON
313+
}
314+
if block.Thinking != nil {
315+
if delta.Thinking != "" {
316+
block.Thinking.Thinking += delta.Thinking
297317
}
298-
if block.Thinking != nil {
299-
if delta.Thinking != "" {
300-
block.Thinking.Thinking += delta.Thinking
301-
}
302-
if delta.Signature != "" {
303-
block.Thinking.Signature = delta.Signature
304-
}
318+
if delta.Signature != "" {
319+
block.Thinking.Signature = delta.Signature
305320
}
306321
}
307322

308323
case event.ContentBlockStop != nil:
309324
idx := event.ContentBlockStop.Index
325+
if !isValidContentBlockIndex(idx) {
326+
continue
327+
}
310328
if jsonStr, ok := toolInputs[idx]; ok {
311329
if idx < len(response.Content) && response.Content[idx].Tool != nil {
312330
var input map[string]any

internal/tracing/openinference/anthropic/messages_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,42 @@ func TestConvertSSEToResponse(t *testing.T) {
238238
Content: []anthropic.MessagesContentBlock{},
239239
},
240240
},
241+
{
242+
// Regression: negative index used to slip through the delta / stop branches
243+
// (idx < len(response.Content) is true for negatives in Go) and panic on the
244+
// slice access. Now guarded by isValidContentBlockIndex.
245+
name: "negative delta and stop indices after a valid start do not panic",
246+
chunks: []*anthropic.MessagesStreamChunk{
247+
{
248+
MessageStart: &anthropic.MessagesStreamChunkMessageStart{
249+
ID: "msg_neg2", Model: "claude-3", Role: "assistant",
250+
Usage: &anthropic.Usage{InputTokens: 1},
251+
},
252+
},
253+
{
254+
ContentBlockStart: &anthropic.MessagesStreamChunkContentBlockStart{
255+
Index: 0,
256+
ContentBlock: anthropic.MessagesContentBlock{Text: &anthropic.TextBlock{Type: "text"}},
257+
},
258+
},
259+
{
260+
ContentBlockDelta: &anthropic.MessagesStreamChunkContentBlockDelta{
261+
Index: -5,
262+
Delta: anthropic.ContentBlockDelta{Type: "text_delta", Text: "malicious"},
263+
},
264+
},
265+
{
266+
ContentBlockStop: &anthropic.MessagesStreamChunkContentBlockStop{Index: -1},
267+
},
268+
},
269+
want: &anthropic.MessagesResponse{
270+
ID: "msg_neg2", Model: "claude-3", Role: "assistant",
271+
Usage: &anthropic.Usage{InputTokens: 1},
272+
Content: []anthropic.MessagesContentBlock{
273+
{Text: &anthropic.TextBlock{Type: "text", Text: ""}},
274+
},
275+
},
276+
},
241277
{
242278
name: "content block index at cap is ignored",
243279
chunks: []*anthropic.MessagesStreamChunk{

0 commit comments

Comments
 (0)