Skip to content

Commit 044b0d3

Browse files
Merge pull request #10 from inflearn/feature/hotfix-triage
fix: two carry-over hotfixes (referencegrant name filter, anthropic tracing negative idx)
2 parents d10579c + 004403b commit 044b0d3

4 files changed

Lines changed: 128 additions & 34 deletions

File tree

internal/controller/referencegrant.go

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ func (v *referenceGrantValidator) validateReference(
9595
// Check if any ReferenceGrant allows this cross-namespace reference.
9696
for i := range referenceGrants.Items {
9797
grant := &referenceGrants.Items[i]
98-
if v.isReferenceGrantValid(grant, routeNamespace, targetGroup, targetKind) {
98+
if v.isReferenceGrantValid(grant, routeNamespace, targetGroup, targetKind, gwapiv1b1.ObjectName(targetName)) {
9999
return nil
100100
}
101101
}
@@ -109,12 +109,13 @@ func (v *referenceGrantValidator) validateReference(
109109
}
110110

111111
// isReferenceGrantValid checks if a ReferenceGrant allows an AIGatewayRoute to reference the
112-
// target resource identified by targetGroup/targetKind.
112+
// target resource identified by targetGroup/targetKind/targetName.
113113
func (v *referenceGrantValidator) isReferenceGrantValid(
114114
grant *gwapiv1b1.ReferenceGrant,
115115
fromNamespace string,
116116
targetGroup gwapiv1b1.Group,
117117
targetKind gwapiv1b1.Kind,
118+
targetName gwapiv1b1.ObjectName,
118119
) bool {
119120
// Check if the grant allows references from the route's namespace.
120121
fromAllowed := false
@@ -131,7 +132,7 @@ func (v *referenceGrantValidator) isReferenceGrantValid(
131132

132133
// Check if the grant allows references to the target resource.
133134
for _, to := range grant.Spec.To {
134-
if v.matchesTo(&to, targetGroup, targetKind) {
135+
if v.matchesTo(&to, targetGroup, targetKind, targetName) {
135136
return true
136137
}
137138
}
@@ -159,22 +160,31 @@ func (v *referenceGrantValidator) matchesFrom(from *gwapiv1b1.ReferenceGrantFrom
159160
return true
160161
}
161162

162-
// matchesTo checks if a ReferenceGrantTo matches the target resource identified by targetGroup/targetKind.
163-
func (v *referenceGrantValidator) matchesTo(to *gwapiv1b1.ReferenceGrantTo, targetGroup gwapiv1b1.Group, targetKind gwapiv1b1.Kind) bool {
164-
// Check group
163+
// matchesTo checks if a ReferenceGrantTo matches the target resource identified by
164+
// targetGroup/targetKind/targetName.
165+
//
166+
// Per the Gateway API spec, ReferenceGrantTo.Name is optional. When set, the grant is scoped
167+
// to exactly that named resource; when unset, it acts as a wildcard covering every resource of
168+
// the group/kind. Previously we ignored the name field entirely, which quietly promoted any
169+
// name-scoped grant into a wildcard and let an AIGatewayRoute reference *any* backend of the
170+
// same kind in the target namespace — not what the grant author meant.
171+
//
172+
// https://gateway-api.sigs.k8s.io/api-types/referencegrant/
173+
func (v *referenceGrantValidator) matchesTo(
174+
to *gwapiv1b1.ReferenceGrantTo,
175+
targetGroup gwapiv1b1.Group,
176+
targetKind gwapiv1b1.Kind,
177+
targetName gwapiv1b1.ObjectName,
178+
) bool {
165179
if to.Group != targetGroup {
166180
return false
167181
}
168-
169-
// Check kind
170182
if to.Kind != targetKind {
171183
return false
172184
}
173-
174-
// If a specific name is specified, we would need to check it here,
175-
// but ReferenceGrant typically doesn't specify individual resource names
176-
// (that's handled by the Name field which is optional in the spec)
177-
// For now, we only check group and kind as per Gateway API spec
178-
185+
// Name is optional in ReferenceGrantTo. Unset == wildcard; set == exact match required.
186+
if to.Name != nil && *to.Name != targetName {
187+
return false
188+
}
179189
return true
180190
}

internal/controller/referencegrant_test.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,36 @@ func TestReferenceGrantValidator_MatchesFrom_WrongNamespace(t *testing.T) {
442442
require.False(t, result, "should return false for wrong namespace")
443443
}
444444

445+
// TestReferenceGrantValidator_MatchesTo_Name locks in the name-filter behaviour: when
446+
// ReferenceGrantTo.Name is set, the grant must apply only to the named resource; when unset,
447+
// it acts as a wildcard for the group/kind.
448+
func TestReferenceGrantValidator_MatchesTo_Name(t *testing.T) {
449+
scheme := runtime.NewScheme()
450+
_ = gwapiv1b1.Install(scheme)
451+
_ = aigv1a1.AddToScheme(scheme)
452+
453+
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
454+
validator := newReferenceGrantValidator(fakeClient)
455+
456+
t.Run("name unset → wildcard, matches any name", func(t *testing.T) {
457+
to := &gwapiv1b1.ReferenceGrantTo{Group: aiServiceBackendGroup, Kind: aiServiceBackendKind}
458+
require.True(t, validator.matchesTo(to, aiServiceBackendGroup, aiServiceBackendKind, "any-backend"))
459+
})
460+
461+
t.Run("name set and matches → allowed", func(t *testing.T) {
462+
named := gwapiv1b1.ObjectName("target-backend")
463+
to := &gwapiv1b1.ReferenceGrantTo{Group: aiServiceBackendGroup, Kind: aiServiceBackendKind, Name: &named}
464+
require.True(t, validator.matchesTo(to, aiServiceBackendGroup, aiServiceBackendKind, "target-backend"))
465+
})
466+
467+
t.Run("name set but differs → denied", func(t *testing.T) {
468+
named := gwapiv1b1.ObjectName("target-backend")
469+
to := &gwapiv1b1.ReferenceGrantTo{Group: aiServiceBackendGroup, Kind: aiServiceBackendKind, Name: &named}
470+
require.False(t, validator.matchesTo(to, aiServiceBackendGroup, aiServiceBackendKind, "other-backend"),
471+
"name-scoped grant must not promote to a wildcard")
472+
})
473+
}
474+
445475
// TestReferenceGrantValidator_MatchesTo_WrongGroup tests matchesTo with wrong group
446476
func TestReferenceGrantValidator_MatchesTo_WrongGroup(t *testing.T) {
447477
scheme := runtime.NewScheme()
@@ -456,7 +486,7 @@ func TestReferenceGrantValidator_MatchesTo_WrongGroup(t *testing.T) {
456486
Kind: aiServiceBackendKind,
457487
}
458488

459-
result := validator.matchesTo(to, aiServiceBackendGroup, aiServiceBackendKind)
489+
result := validator.matchesTo(to, aiServiceBackendGroup, aiServiceBackendKind, "target-backend")
460490
require.False(t, result, "should return false for wrong group")
461491
}
462492

@@ -474,7 +504,7 @@ func TestReferenceGrantValidator_MatchesTo_WrongKind(t *testing.T) {
474504
Kind: "WrongKind",
475505
}
476506

477-
result := validator.matchesTo(to, aiServiceBackendGroup, aiServiceBackendKind)
507+
result := validator.matchesTo(to, aiServiceBackendGroup, aiServiceBackendKind, "target-backend")
478508
require.False(t, result, "should return false for wrong kind")
479509
}
480510

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)