Skip to content

Commit 8b993cd

Browse files
committed
BUG/MINOR: enforce allowedRoutes.namespaces in route-to-gateway attachment
The Gateway API spec requires that listeners enforce spec.listeners[*].allowedRoutes.namespaces before accepting a route. HUG was skipping this check entirely, letting routes from any namespace attach regardless of the listener's namespace policy. Add isRouteNamespaceAllowed() in route_namespace_filter.go to implement the three policy modes: - From=Same : route namespace must equal the gateway namespace - From=All : any route namespace is allowed - From=Selector: route's namespace labels must match the LabelSelector The default when AllowedRoutes.Namespaces or From is nil is Same, per the Gateway API spec. Wire the check into HTTPRoute.checkParentRef() and TLSRoute.checkParentRef(), immediately after the route-kind check and before addAttachedRoute(), so that a namespace mismatch produces the same NotAllowedByListeners condition as a kind mismatch. This fixes the GatewayHTTPConformance test HTTPRouteCrossNamespace when it follows GatewaySecretReferenceGrantSpecific: the latter creates a gateway with from=All, causing incorrect cross-namespace attachments via the shared frontend without the namespace filter in place.
1 parent e0a6f9c commit 8b993cd

4 files changed

Lines changed: 260 additions & 0 deletions

File tree

k8s/gate/tree/HTTPRoute.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,12 @@ func (r *HTTPRoute) checkParentRef(parentRef gatewayv1.ParentReference, controll
269269
continue
270270
}
271271

272+
// Check if the route's namespace is permitted by allowedRoutes.namespaces
273+
if !isRouteNamespaceAllowed(r.K8sResource.Namespace, listener, treeGw.K8sResource.Namespace, controllerStore.ClusterStore.Namespaces) {
274+
conds.MergeOverrideConditions(rc.ConditionNotAcceptedRouteReasonNotAllowedByListeners())
275+
continue
276+
}
277+
272278
// Add the route to the listener
273279
listener.addAttachedRoute(client.ObjectKeyFromObject(r.K8sResource), controllerStore)
274280
validListeners = append(validListeners, listener)

k8s/gate/tree/TLSRoute.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,12 @@ func (r *TLSRoute) checkParentRef(parentRef gatewayv1.ParentReference, controlle
247247
continue
248248
}
249249

250+
// Check if the route's namespace is permitted by allowedRoutes.namespaces
251+
if !isRouteNamespaceAllowed(r.K8sResource.Namespace, listener, treeGw.K8sResource.Namespace, controllerStore.ClusterStore.Namespaces) {
252+
conds.MergeOverrideConditions(rc.ConditionNotAcceptedRouteReasonNotAllowedByListeners())
253+
continue
254+
}
255+
250256
// Listener valide, attache la route
251257
listener.addAttachedRoute(client.ObjectKeyFromObject(r.K8sResource), controllerStore)
252258
validListeners = append(validListeners, listener)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright 2025 HAProxy Technologies LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
package tree
15+
16+
import (
17+
v1 "k8s.io/api/core/v1"
18+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
19+
"k8s.io/apimachinery/pkg/labels"
20+
"k8s.io/apimachinery/pkg/types"
21+
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
22+
)
23+
24+
// isRouteNamespaceAllowed reports whether a route in routeNamespace is permitted to
25+
// attach to listener, which belongs to a gateway in gatewayNamespace.
26+
//
27+
// The decision follows spec.listeners[*].allowedRoutes.namespaces:
28+
// - nil / From=Same → only routes in the same namespace as the gateway
29+
// - From=All → routes from any namespace
30+
// - From=Selector → routes from namespaces whose labels match the selector
31+
//
32+
// The default when From is absent is Same (per the Gateway API spec).
33+
func isRouteNamespaceAllowed(
34+
routeNamespace string,
35+
listener *Listener,
36+
gatewayNamespace string,
37+
namespaces map[types.NamespacedName]*v1.Namespace,
38+
) bool {
39+
allowedRoutes := listener.K8sResource.AllowedRoutes
40+
if allowedRoutes == nil || allowedRoutes.Namespaces == nil || allowedRoutes.Namespaces.From == nil {
41+
return routeNamespace == gatewayNamespace
42+
}
43+
44+
switch *allowedRoutes.Namespaces.From {
45+
case gatewayv1.NamespacesFromAll:
46+
return true
47+
case gatewayv1.NamespacesFromSame:
48+
return routeNamespace == gatewayNamespace
49+
case gatewayv1.NamespacesFromSelector:
50+
sel := allowedRoutes.Namespaces.Selector
51+
if sel == nil {
52+
return false
53+
}
54+
labelSel, err := metav1.LabelSelectorAsSelector(sel)
55+
if err != nil {
56+
return false
57+
}
58+
ns, ok := namespaces[types.NamespacedName{Name: routeNamespace}]
59+
if !ok || ns == nil {
60+
return false
61+
}
62+
return labelSel.Matches(labels.Set(ns.Labels))
63+
default:
64+
return false
65+
}
66+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Copyright 2025 HAProxy Technologies LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
package tree
15+
16+
import (
17+
"testing"
18+
19+
"github.com/stretchr/testify/assert"
20+
v1 "k8s.io/api/core/v1"
21+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
22+
"k8s.io/apimachinery/pkg/types"
23+
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
24+
)
25+
26+
func makeListener(from *gatewayv1.FromNamespaces, selector *metav1.LabelSelector) *Listener {
27+
l := &Listener{}
28+
if from != nil {
29+
ns := &gatewayv1.RouteNamespaces{From: from}
30+
if selector != nil {
31+
ns.Selector = selector
32+
}
33+
l.K8sResource = gatewayv1.Listener{
34+
AllowedRoutes: &gatewayv1.AllowedRoutes{Namespaces: ns},
35+
}
36+
}
37+
return l
38+
}
39+
40+
func makeNamespaces(names ...string) map[types.NamespacedName]*v1.Namespace {
41+
m := make(map[types.NamespacedName]*v1.Namespace, len(names))
42+
for _, name := range names {
43+
m[types.NamespacedName{Name: name}] = &v1.Namespace{
44+
ObjectMeta: metav1.ObjectMeta{Name: name},
45+
}
46+
}
47+
return m
48+
}
49+
50+
func makeNamespacesWithLabels(labels map[string]map[string]string) map[types.NamespacedName]*v1.Namespace {
51+
m := make(map[types.NamespacedName]*v1.Namespace, len(labels))
52+
for name, lbls := range labels {
53+
m[types.NamespacedName{Name: name}] = &v1.Namespace{
54+
ObjectMeta: metav1.ObjectMeta{Name: name, Labels: lbls},
55+
}
56+
}
57+
return m
58+
}
59+
60+
//revive:disable:function-length
61+
func Test_isRouteNamespaceAllowed(t *testing.T) {
62+
fromSame := gatewayv1.NamespacesFromSame
63+
fromAll := gatewayv1.NamespacesFromAll
64+
fromSelector := gatewayv1.NamespacesFromSelector
65+
66+
tests := []struct {
67+
name string
68+
routeNamespace string
69+
gatewayNamespace string
70+
listener *Listener
71+
namespaces map[types.NamespacedName]*v1.Namespace
72+
want bool
73+
}{
74+
{
75+
name: "nil AllowedRoutes defaults to Same, same namespace",
76+
routeNamespace: "ns-a",
77+
gatewayNamespace: "ns-a",
78+
listener: &Listener{},
79+
namespaces: makeNamespaces("ns-a"),
80+
want: true,
81+
},
82+
{
83+
name: "nil AllowedRoutes defaults to Same, different namespace",
84+
routeNamespace: "ns-b",
85+
gatewayNamespace: "ns-a",
86+
listener: &Listener{},
87+
namespaces: makeNamespaces("ns-a", "ns-b"),
88+
want: false,
89+
},
90+
{
91+
name: "from=Same, same namespace",
92+
routeNamespace: "ns-a",
93+
gatewayNamespace: "ns-a",
94+
listener: makeListener(&fromSame, nil),
95+
namespaces: makeNamespaces("ns-a"),
96+
want: true,
97+
},
98+
{
99+
name: "from=Same, different namespace",
100+
routeNamespace: "ns-b",
101+
gatewayNamespace: "ns-a",
102+
listener: makeListener(&fromSame, nil),
103+
namespaces: makeNamespaces("ns-a", "ns-b"),
104+
want: false,
105+
},
106+
{
107+
name: "from=All, same namespace",
108+
routeNamespace: "ns-a",
109+
gatewayNamespace: "ns-a",
110+
listener: makeListener(&fromAll, nil),
111+
namespaces: makeNamespaces("ns-a"),
112+
want: true,
113+
},
114+
{
115+
name: "from=All, different namespace",
116+
routeNamespace: "ns-b",
117+
gatewayNamespace: "ns-a",
118+
listener: makeListener(&fromAll, nil),
119+
namespaces: makeNamespaces("ns-a", "ns-b"),
120+
want: true,
121+
},
122+
{
123+
name: "from=Selector, namespace labels match",
124+
routeNamespace: "ns-b",
125+
gatewayNamespace: "ns-a",
126+
listener: makeListener(&fromSelector, &metav1.LabelSelector{
127+
MatchLabels: map[string]string{"team": "infra"},
128+
}),
129+
namespaces: makeNamespacesWithLabels(map[string]map[string]string{
130+
"ns-a": {"team": "gateway"},
131+
"ns-b": {"team": "infra"},
132+
}),
133+
want: true,
134+
},
135+
{
136+
name: "from=Selector, namespace labels do not match",
137+
routeNamespace: "ns-b",
138+
gatewayNamespace: "ns-a",
139+
listener: makeListener(&fromSelector, &metav1.LabelSelector{
140+
MatchLabels: map[string]string{"team": "infra"},
141+
}),
142+
namespaces: makeNamespacesWithLabels(map[string]map[string]string{
143+
"ns-a": {"team": "gateway"},
144+
"ns-b": {"team": "backend"},
145+
}),
146+
want: false,
147+
},
148+
{
149+
name: "from=Selector, namespace not in store",
150+
routeNamespace: "ns-unknown",
151+
gatewayNamespace: "ns-a",
152+
listener: makeListener(&fromSelector, &metav1.LabelSelector{
153+
MatchLabels: map[string]string{"team": "infra"},
154+
}),
155+
namespaces: makeNamespaces("ns-a"),
156+
want: false,
157+
},
158+
{
159+
name: "from=Selector, nil selector returns false",
160+
routeNamespace: "ns-b",
161+
gatewayNamespace: "ns-a",
162+
listener: makeListener(&fromSelector, nil),
163+
namespaces: makeNamespaces("ns-a", "ns-b"),
164+
want: false,
165+
},
166+
{
167+
name: "from=Selector, empty selector matches all namespaces",
168+
routeNamespace: "ns-b",
169+
gatewayNamespace: "ns-a",
170+
listener: makeListener(&fromSelector, &metav1.LabelSelector{}),
171+
namespaces: makeNamespaces("ns-a", "ns-b"),
172+
want: true,
173+
},
174+
}
175+
176+
for _, tt := range tests {
177+
t.Run(tt.name, func(t *testing.T) {
178+
got := isRouteNamespaceAllowed(tt.routeNamespace, tt.listener, tt.gatewayNamespace, tt.namespaces)
179+
assert.Equal(t, tt.want, got)
180+
})
181+
}
182+
}

0 commit comments

Comments
 (0)