Skip to content

Commit 60bcd03

Browse files
committed
Validate agent token for duplicate IP addresses
Problem: In some environments, Pods could share an IP address (for example when a Job completes and a new Pod grabs that IP). This would cause problems when the nginx data plane Pod would attempt to connect to the control plane. The control plane would try to validate the token provided by the nginx agent, by using the IP address in the request to lookup the associated Pod. If multiple Pods existed with that IP address, the control plane would error out. Solution: Fix the control plane logic to use more criteria when getting the proper Pod to verify the provided token.
1 parent e138d64 commit 60bcd03

File tree

2 files changed

+160
-54
lines changed

2 files changed

+160
-54
lines changed

internal/controller/nginx/agent/grpc/interceptor/interceptor.go

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
authv1 "k8s.io/api/authentication/v1"
1717
corev1 "k8s.io/api/core/v1"
1818
"k8s.io/apimachinery/pkg/fields"
19+
"k8s.io/apimachinery/pkg/labels"
1920
"sigs.k8s.io/controller-runtime/pkg/client"
2021

2122
grpcContext "github.com/nginx/nginx-gateway-fabric/v2/internal/controller/nginx/agent/grpc/context"
@@ -160,35 +161,25 @@ func (c ContextSetter) validateToken(ctx context.Context, gi *grpcContext.GrpcIn
160161
var podList corev1.PodList
161162
opts := &client.ListOptions{
162163
FieldSelector: fields.SelectorFromSet(fields.Set{"status.podIP": gi.IPAddress}),
164+
Namespace: usernameItems[2],
165+
LabelSelector: labels.Set(map[string]string{
166+
controller.AppNameLabel: usernameItems[3],
167+
}).AsSelector(),
163168
}
164169

165170
if err := c.k8sClient.List(getCtx, &podList, opts); err != nil {
166171
return nil, status.Error(codes.Internal, fmt.Sprintf("error listing pods: %s", err.Error()))
167172
}
168173

169-
if len(podList.Items) != 1 {
170-
msg := fmt.Sprintf("expected one Pod to have IP address %s, found %d", gi.IPAddress, len(podList.Items))
171-
return nil, status.Error(codes.Internal, msg)
172-
}
173-
174-
podNS := podList.Items[0].GetNamespace()
175-
if podNS != usernameItems[2] {
176-
msg := fmt.Sprintf(
177-
"token user namespace %q does not match namespace of requesting pod %q", usernameItems[2], podNS,
178-
)
179-
return nil, status.Error(codes.Unauthenticated, msg)
180-
}
181-
182-
scName, ok := podList.Items[0].GetLabels()[controller.AppNameLabel]
183-
if !ok {
184-
msg := fmt.Sprintf("could not get app name from %q label; unable to authenticate token", controller.AppNameLabel)
185-
return nil, status.Error(codes.Unauthenticated, msg)
174+
runningCount := 0
175+
for _, pod := range podList.Items {
176+
if pod.Status.Phase == corev1.PodRunning {
177+
runningCount++
178+
}
186179
}
187180

188-
if scName != usernameItems[3] {
189-
msg := fmt.Sprintf(
190-
"token user name %q does not match service account name of requesting pod %q", usernameItems[3], scName,
191-
)
181+
if runningCount != 1 {
182+
msg := fmt.Sprintf("expected a single Running pod with IP address %q, but found %d", gi.IPAddress, runningCount)
192183
return nil, status.Error(codes.Unauthenticated, msg)
193184
}
194185

internal/controller/nginx/agent/grpc/interceptor/interceptor_test.go

Lines changed: 148 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import (
1717
corev1 "k8s.io/api/core/v1"
1818
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1919
"sigs.k8s.io/controller-runtime/pkg/client"
20+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
2021

22+
grpcContext "github.com/nginx/nginx-gateway-fabric/v2/internal/controller/nginx/agent/grpc/context"
2123
"github.com/nginx/nginx-gateway-fabric/v2/internal/framework/controller"
2224
)
2325

@@ -67,6 +69,7 @@ func (m *mockClient) List(_ context.Context, obj client.ObjectList, _ ...client.
6769
Namespace: m.podNamespace,
6870
Labels: labels,
6971
},
72+
Status: corev1.PodStatus{Phase: corev1.PodRunning},
7073
},
7174
}
7275

@@ -204,38 +207,6 @@ func TestInterceptor(t *testing.T) {
204207
expErrCode: codes.Unauthenticated,
205208
expErrMsg: "must be of the format",
206209
},
207-
{
208-
name: "mismatched namespace in username",
209-
md: validMetadata,
210-
peer: validPeerData,
211-
username: "system:serviceaccount:invalid:gateway-nginx",
212-
appName: "gateway-nginx",
213-
podNamespace: "default",
214-
authenticated: true,
215-
expErrCode: codes.Unauthenticated,
216-
expErrMsg: "does not match namespace",
217-
},
218-
{
219-
name: "mismatched name in username",
220-
md: validMetadata,
221-
peer: validPeerData,
222-
username: "system:serviceaccount:default:invalid",
223-
appName: "gateway-nginx",
224-
podNamespace: "default",
225-
authenticated: true,
226-
expErrCode: codes.Unauthenticated,
227-
expErrMsg: "does not match service account name",
228-
},
229-
{
230-
name: "missing app name label",
231-
md: validMetadata,
232-
peer: validPeerData,
233-
username: "system:serviceaccount:default:gateway-nginx",
234-
podNamespace: "default",
235-
authenticated: true,
236-
expErrCode: codes.Unauthenticated,
237-
expErrMsg: "could not get app name",
238-
},
239210
}
240211

241212
streamHandler := func(_ any, _ grpc.ServerStream) error {
@@ -261,7 +232,7 @@ func TestInterceptor(t *testing.T) {
261232
}
262233
cs := NewContextSetter(mockK8sClient, "ngf-audience")
263234

264-
ctx := context.Background()
235+
ctx := t.Context()
265236
if test.md != nil {
266237
peerCtx := context.Background()
267238
if test.peer != nil {
@@ -290,3 +261,147 @@ func TestInterceptor(t *testing.T) {
290261
})
291262
}
292263
}
264+
265+
type patchClient struct {
266+
client.Client
267+
}
268+
269+
func (p *patchClient) Create(_ context.Context, obj client.Object, _ ...client.CreateOption) error {
270+
tr, ok := obj.(*authv1.TokenReview)
271+
if ok {
272+
tr.Status.Authenticated = true
273+
tr.Status.User.Username = "system:serviceaccount:default:gateway-nginx"
274+
}
275+
return nil
276+
}
277+
278+
func TestValidateToken_PodListOptions(t *testing.T) {
279+
t.Parallel()
280+
281+
testCases := []struct {
282+
pod *corev1.Pod
283+
gi *grpcContext.GrpcInfo
284+
name string
285+
shouldErr bool
286+
}{
287+
{
288+
name: "all match",
289+
pod: &corev1.Pod{
290+
ObjectMeta: metav1.ObjectMeta{
291+
Name: "nginx-pod",
292+
Namespace: "default",
293+
Labels: map[string]string{
294+
controller.AppNameLabel: "gateway-nginx",
295+
},
296+
},
297+
Status: corev1.PodStatus{PodIP: "1.2.3.4", Phase: corev1.PodRunning},
298+
},
299+
gi: &grpcContext.GrpcInfo{Token: "dummy-token", IPAddress: "1.2.3.4"},
300+
shouldErr: false,
301+
},
302+
{
303+
name: "ip matches, namespace does not",
304+
pod: &corev1.Pod{
305+
ObjectMeta: metav1.ObjectMeta{
306+
Name: "nginx-pod",
307+
Namespace: "other-namespace",
308+
Labels: map[string]string{
309+
controller.AppNameLabel: "gateway-nginx",
310+
},
311+
},
312+
Status: corev1.PodStatus{PodIP: "1.2.3.4", Phase: corev1.PodRunning},
313+
},
314+
gi: &grpcContext.GrpcInfo{Token: "dummy-token", IPAddress: "1.2.3.4"},
315+
shouldErr: true,
316+
},
317+
{
318+
name: "ip matches, label value does not match",
319+
pod: &corev1.Pod{
320+
ObjectMeta: metav1.ObjectMeta{
321+
Name: "nginx-pod",
322+
Namespace: "default",
323+
Labels: map[string]string{
324+
controller.AppNameLabel: "not-gateway-nginx",
325+
},
326+
},
327+
Status: corev1.PodStatus{PodIP: "1.2.3.4", Phase: corev1.PodRunning},
328+
},
329+
gi: &grpcContext.GrpcInfo{Token: "dummy-token", IPAddress: "1.2.3.4"},
330+
shouldErr: true,
331+
},
332+
{
333+
name: "ip matches, label does not exist",
334+
pod: &corev1.Pod{
335+
ObjectMeta: metav1.ObjectMeta{
336+
Name: "nginx-pod",
337+
Namespace: "default",
338+
Labels: map[string]string{},
339+
},
340+
Status: corev1.PodStatus{PodIP: "1.2.3.4", Phase: corev1.PodRunning},
341+
},
342+
gi: &grpcContext.GrpcInfo{Token: "dummy-token", IPAddress: "1.2.3.4"},
343+
shouldErr: true,
344+
},
345+
{
346+
name: "ip does not match",
347+
pod: &corev1.Pod{
348+
ObjectMeta: metav1.ObjectMeta{
349+
Name: "nginx-pod",
350+
Namespace: "default",
351+
Labels: map[string]string{
352+
controller.AppNameLabel: "gateway-nginx",
353+
},
354+
},
355+
Status: corev1.PodStatus{PodIP: "1.2.3.4", Phase: corev1.PodRunning},
356+
},
357+
gi: &grpcContext.GrpcInfo{Token: "dummy-token", IPAddress: "9.9.9.9"},
358+
shouldErr: true,
359+
},
360+
{
361+
name: "all match but pod not running",
362+
pod: &corev1.Pod{
363+
ObjectMeta: metav1.ObjectMeta{
364+
Name: "nginx-pod",
365+
Namespace: "default",
366+
Labels: map[string]string{
367+
controller.AppNameLabel: "gateway-nginx",
368+
},
369+
},
370+
Status: corev1.PodStatus{PodIP: "1.2.3.4", Phase: corev1.PodPending},
371+
},
372+
gi: &grpcContext.GrpcInfo{Token: "dummy-token", IPAddress: "1.2.3.4"},
373+
shouldErr: true,
374+
},
375+
}
376+
377+
for _, tc := range testCases {
378+
t.Run(tc.name, func(t *testing.T) {
379+
t.Parallel()
380+
g := NewWithT(t)
381+
382+
fakeClient := fake.NewClientBuilder().
383+
WithObjects(tc.pod).
384+
WithIndex(&corev1.Pod{}, "status.podIP", func(obj client.Object) []string {
385+
pod, ok := obj.(*corev1.Pod)
386+
g.Expect(ok).To(BeTrue())
387+
if pod.Status.PodIP != "" {
388+
return []string{pod.Status.PodIP}
389+
}
390+
return nil
391+
}).
392+
Build()
393+
394+
patchedClient := &patchClient{fakeClient}
395+
csPatched := NewContextSetter(patchedClient, "ngf-audience")
396+
397+
resultCtx, err := csPatched.validateToken(t.Context(), tc.gi)
398+
if tc.shouldErr {
399+
g.Expect(err).To(HaveOccurred())
400+
g.Expect(err.Error()).To(ContainSubstring("expected a single Running pod"))
401+
} else {
402+
g.Expect(err).ToNot(HaveOccurred())
403+
g.Expect(resultCtx).ToNot(BeNil())
404+
}
405+
})
406+
}
407+
}

0 commit comments

Comments
 (0)