Skip to content

Commit 52fab05

Browse files
authored
Add NKG-specific field validation for HTTPRoutes (#455)
* Add NKG-specific field validation for HTTPRoutes - Introduce HTTPFieldsValidator interface for validating fields of HTTP-related Gateway API resources according to the data-plane specific rules. - Validate HTTPRoute resources when building the graph using data-plane agnostic rules. - Validate HTTPRoute resources when building the graph using HTTPFieldsValidator according to the data-plane rules. - Implement an HTTPFieldsValidator for NGINX-specific validation rules. Fixes #412 * Apply suggestions on GitHub Co-authored-by: Kate Osborn <[email protected]>
1 parent 1933145 commit 52fab05

40 files changed

+4760
-1214
lines changed

internal/helpers/helpers.go

+7-2
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import (
99
// Diff prints the diff between two structs.
1010
// It is useful in testing to compare two structs when they are large. In such a case, without Diff it will be difficult
1111
// to pinpoint the difference between the two structs.
12-
func Diff(x, y interface{}) string {
13-
r := cmp.Diff(x, y)
12+
func Diff(want, got any) string {
13+
r := cmp.Diff(want, got)
1414

1515
if r != "" {
1616
return "(-want +got)\n" + r
@@ -57,3 +57,8 @@ func GetTLSModePointer(t v1beta1.TLSModeType) *v1beta1.TLSModeType {
5757
func GetBoolPointer(b bool) *bool {
5858
return &b
5959
}
60+
61+
// GetPointer takes a value of any type and returns a pointer to it.
62+
func GetPointer[T any](v T) *T {
63+
return &v
64+
}

internal/manager/manager.go

+8-3
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,22 @@ import (
1414
"sigs.k8s.io/controller-runtime/pkg/manager"
1515
k8spredicate "sigs.k8s.io/controller-runtime/pkg/predicate"
1616
gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1"
17-
"sigs.k8s.io/gateway-api/apis/v1beta1/validation"
17+
gwapivalidation "sigs.k8s.io/gateway-api/apis/v1beta1/validation"
1818

1919
"github.com/nginxinc/nginx-kubernetes-gateway/internal/config"
2020
"github.com/nginxinc/nginx-kubernetes-gateway/internal/events"
2121
"github.com/nginxinc/nginx-kubernetes-gateway/internal/manager/filter"
2222
"github.com/nginxinc/nginx-kubernetes-gateway/internal/manager/index"
2323
"github.com/nginxinc/nginx-kubernetes-gateway/internal/manager/predicate"
2424
ngxcfg "github.com/nginxinc/nginx-kubernetes-gateway/internal/nginx/config"
25+
ngxvalidation "github.com/nginxinc/nginx-kubernetes-gateway/internal/nginx/config/validation"
2526
"github.com/nginxinc/nginx-kubernetes-gateway/internal/nginx/file"
2627
ngxruntime "github.com/nginxinc/nginx-kubernetes-gateway/internal/nginx/runtime"
2728
"github.com/nginxinc/nginx-kubernetes-gateway/internal/state"
2829
"github.com/nginxinc/nginx-kubernetes-gateway/internal/state/relationship"
2930
"github.com/nginxinc/nginx-kubernetes-gateway/internal/state/resolver"
3031
"github.com/nginxinc/nginx-kubernetes-gateway/internal/state/secrets"
32+
"github.com/nginxinc/nginx-kubernetes-gateway/internal/state/validation"
3133
"github.com/nginxinc/nginx-kubernetes-gateway/internal/status"
3234
)
3335

@@ -80,13 +82,13 @@ func Start(cfg config.Config) error {
8082
{
8183
objectType: &gatewayv1beta1.Gateway{},
8284
options: []controllerOption{
83-
withWebhookValidator(createValidator(validation.ValidateGateway)),
85+
withWebhookValidator(createValidator(gwapivalidation.ValidateGateway)),
8486
},
8587
},
8688
{
8789
objectType: &gatewayv1beta1.HTTPRoute{},
8890
options: []controllerOption{
89-
withWebhookValidator(createValidator(validation.ValidateHTTPRoute)),
91+
withWebhookValidator(createValidator(gwapivalidation.ValidateHTTPRoute)),
9092
},
9193
},
9294
{
@@ -129,6 +131,9 @@ func Start(cfg config.Config) error {
129131
ServiceResolver: resolver.NewServiceResolverImpl(mgr.GetClient()),
130132
RelationshipCapturer: relationship.NewCapturerImpl(),
131133
Logger: cfg.Logger.WithName("changeProcessor"),
134+
Validators: validation.Validators{
135+
HTTPFieldsValidator: ngxvalidation.HTTPValidator{},
136+
},
132137
})
133138

134139
configGenerator := ngxcfg.NewGeneratorImpl()

internal/nginx/config/generator.go

+4
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ func NewGeneratorImpl() GeneratorImpl {
2424
// executeFunc is a function that generates NGINX configuration from internal representation.
2525
type executeFunc func(configuration dataplane.Configuration) []byte
2626

27+
// Generate generates NGINX configuration from internal representation.
28+
// It is the responsibility of the caller to validate the configuration before calling this function.
29+
// In case of invalid configuration, NGINX will fail to reload or could be configured with malicious configuration.
30+
// To validate, use the validators from the validation package.
2731
func (g GeneratorImpl) Generate(conf dataplane.Configuration) []byte {
2832
var generated []byte
2933
for _, execute := range getExecuteFuncs() {

internal/nginx/config/http/config.go

+3-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ type Location struct {
2020

2121
// Return represents an HTTP return.
2222
type Return struct {
23-
URL string
23+
Body string
2424
Code StatusCode
2525
}
2626

@@ -38,6 +38,8 @@ const (
3838
StatusFound StatusCode = 302
3939
// StatusNotFound is the HTTP 404 status code.
4040
StatusNotFound StatusCode = 404
41+
// StatusInternalServerError is the HTTP 500 status code.
42+
StatusInternalServerError StatusCode = 500
4143
)
4244

4345
// Upstream holds all configuration for an HTTP upstream.

internal/nginx/config/servers.go

+15-12
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import (
1414

1515
var serversTemplate = gotemplate.Must(gotemplate.New("servers").Parse(serversTemplateText))
1616

17-
const rootPath = "/"
17+
const (
18+
// HeaderMatchSeparator is the separator for constructing header-based match for NJS.
19+
HeaderMatchSeparator = ":"
20+
rootPath = "/"
21+
)
1822

1923
func executeServers(conf dataplane.Configuration) []byte {
2024
servers := createServers(conf.HTTPServers, conf.SSLServers)
@@ -106,16 +110,19 @@ func createLocations(pathRules []dataplane.PathRule, listenerPort int) []http.Lo
106110
matches = append(matches, createHTTPMatch(m, path))
107111
}
108112

109-
// FIXME(pleshakov): There could be a case when the filter has the type set but not the corresponding field.
113+
if r.Filters.InvalidFilter != nil {
114+
loc.Return = &http.Return{Code: http.StatusInternalServerError}
115+
locs = append(locs, loc)
116+
continue
117+
}
118+
119+
// There could be a case when the filter has the type set but not the corresponding field.
110120
// For example, type is v1beta1.HTTPRouteFilterRequestRedirect, but RequestRedirect field is nil.
111-
// The validation webhook catches that.
112-
// If it doesn't work as expected, such situation is silently handled below in findFirstFilters.
113-
// Consider reporting an error. But that should be done in a separate validation layer.
121+
// The imported Webhook validation webhook catches that.
114122

115123
// RequestRedirect and proxying are mutually exclusive.
116124
if r.Filters.RequestRedirect != nil {
117125
loc.Return = createReturnValForRedirectFilter(r.Filters.RequestRedirect, listenerPort)
118-
119126
locs = append(locs, loc)
120127
continue
121128
}
@@ -172,9 +179,6 @@ func createReturnValForRedirectFilter(filter *v1beta1.HTTPRequestRedirectFilter,
172179
hostname = string(*filter.Hostname)
173180
}
174181

175-
// FIXME(pleshakov): Unknown values here must result in the implementation setting the Attached Condition for
176-
// the Route to `status: False`, with a Reason of `UnsupportedValue`. In that case, all routes of the Route will be
177-
// ignored. NGINX will return 500. This should be implemented in the validation layer.
178182
code := http.StatusFound
179183
if filter.StatusCode != nil {
180184
code = http.StatusCode(*filter.StatusCode)
@@ -185,15 +189,14 @@ func createReturnValForRedirectFilter(filter *v1beta1.HTTPRequestRedirectFilter,
185189
port = int(*filter.Port)
186190
}
187191

188-
// FIXME(pleshakov): Same as the FIXME about StatusCode above.
189192
scheme := "$scheme"
190193
if filter.Scheme != nil {
191194
scheme = *filter.Scheme
192195
}
193196

194197
return &http.Return{
195198
Code: code,
196-
URL: fmt.Sprintf("%s://%s:%d$request_uri", scheme, hostname, port),
199+
Body: fmt.Sprintf("%s://%s:%d$request_uri", scheme, hostname, port),
197200
}
198201
}
199202

@@ -275,7 +278,7 @@ func createQueryParamKeyValString(p v1beta1.HTTPQueryParamMatch) string {
275278
// We preserve the case of the name here because NGINX allows us to look up the header names in a case-insensitive
276279
// manner.
277280
func createHeaderKeyValString(h v1beta1.HTTPHeaderMatch) string {
278-
return string(h.Name) + ":" + h.Value
281+
return string(h.Name) + HeaderMatchSeparator + h.Value
279282
}
280283

281284
func isPathOnlyMatch(match v1beta1.HTTPRouteMatch) bool {

internal/nginx/config/servers_template.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ server {
3636
{{ end }}
3737
3838
{{ if $l.Return }}
39-
return {{ $l.Return.Code }} {{ $l.Return.URL }};
39+
return {{ $l.Return.Code }} "{{ $l.Return.Body }}";
4040
{{ end }}
4141
4242
{{ if $l.HTTPMatchVar }}

internal/nginx/config/servers_test.go

+39-8
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,16 @@ func TestCreateServers(t *testing.T) {
271271
},
272272
// redirect is set in the corresponding state.MatchRule
273273
},
274+
{
275+
// A match with an invalid filter
276+
Matches: []v1beta1.HTTPRouteMatch{
277+
{
278+
Path: &v1beta1.HTTPPathMatch{
279+
Value: helpers.GetPointer("/invalid-filter"),
280+
},
281+
},
282+
},
283+
},
274284
},
275285
},
276286
}
@@ -324,6 +334,8 @@ func TestCreateServers(t *testing.T) {
324334

325335
filterGroup2 := graph.BackendGroup{Source: hrNsName, RuleIdx: 4}
326336

337+
invalidFilterGroup := graph.BackendGroup{Source: hrNsName, RuleIdx: 5}
338+
327339
cafePathRules := []dataplane.PathRule{
328340
{
329341
Path: "/",
@@ -403,6 +415,20 @@ func TestCreateServers(t *testing.T) {
403415
},
404416
},
405417
},
418+
{
419+
Path: "/invalid-filter",
420+
MatchRules: []dataplane.MatchRule{
421+
{
422+
MatchIdx: 0,
423+
RuleIdx: 5,
424+
Source: hr,
425+
Filters: dataplane.Filters{
426+
InvalidFilter: &dataplane.InvalidFilter{},
427+
},
428+
BackendGroup: invalidFilterGroup,
429+
},
430+
},
431+
},
406432
}
407433

408434
httpServers := []dataplane.VirtualServer{
@@ -491,14 +517,20 @@ func TestCreateServers(t *testing.T) {
491517
Path: "/redirect-implicit-port",
492518
Return: &http.Return{
493519
Code: 302,
494-
URL: fmt.Sprintf("$scheme://foo.example.com:%d$request_uri", port),
520+
Body: fmt.Sprintf("$scheme://foo.example.com:%d$request_uri", port),
495521
},
496522
},
497523
{
498524
Path: "/redirect-explicit-port",
499525
Return: &http.Return{
500526
Code: 302,
501-
URL: "$scheme://bar.example.com:8080$request_uri",
527+
Body: "$scheme://bar.example.com:8080$request_uri",
528+
},
529+
},
530+
{
531+
Path: "/invalid-filter",
532+
Return: &http.Return{
533+
Code: http.StatusInternalServerError,
502534
},
503535
},
504536
}
@@ -522,11 +554,10 @@ func TestCreateServers(t *testing.T) {
522554
},
523555
}
524556

525-
result := createServers(httpServers, sslServers)
557+
g := NewGomegaWithT(t)
526558

527-
if diff := cmp.Diff(expectedServers, result); diff != "" {
528-
t.Errorf("createServers() mismatch (-want +got):\n%s", diff)
529-
}
559+
result := createServers(httpServers, sslServers)
560+
g.Expect(helpers.Diff(expectedServers, result)).To(BeEmpty())
530561
}
531562

532563
func TestCreateLocationsRootPath(t *testing.T) {
@@ -713,7 +744,7 @@ func TestCreateReturnValForRedirectFilter(t *testing.T) {
713744
filter: &v1beta1.HTTPRequestRedirectFilter{},
714745
expected: &http.Return{
715746
Code: http.StatusFound,
716-
URL: "$scheme://$host:123$request_uri",
747+
Body: "$scheme://$host:123$request_uri",
717748
},
718749
msg: "all fields are empty",
719750
},
@@ -726,7 +757,7 @@ func TestCreateReturnValForRedirectFilter(t *testing.T) {
726757
},
727758
expected: &http.Return{
728759
Code: 101,
729-
URL: "https://foo.example.com:2022$request_uri",
760+
Body: "https://foo.example.com:2022$request_uri",
730761
},
731762
msg: "all fields are set",
732763
},
+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package validation
2+
3+
import (
4+
"errors"
5+
"regexp"
6+
7+
k8svalidation "k8s.io/apimachinery/pkg/util/validation"
8+
)
9+
10+
const (
11+
escapedStringsFmt = `([^"\\]|\\.)*`
12+
escapedStringsErrMsg = `must have all '"' (double quotes) escaped and must not end with an unescaped '\' ` +
13+
`(backslash)`
14+
)
15+
16+
var escapedStringsFmtRegexp = regexp.MustCompile("^" + escapedStringsFmt + "$")
17+
18+
// validateEscapedString is used to validate a string that is surrounded by " in the NGINX config for a directive
19+
// that doesn't support any regex rules or variables (it doesn't try to expand the variable name behind $).
20+
// For example, server_name "hello $not_a_var world"
21+
// If the value is invalid, the function returns an error that includes the specified examples of valid values.
22+
func validateEscapedString(value string, examples []string) error {
23+
if !escapedStringsFmtRegexp.MatchString(value) {
24+
msg := k8svalidation.RegexError(escapedStringsErrMsg, escapedStringsFmt, examples...)
25+
return errors.New(msg)
26+
}
27+
return nil
28+
}
29+
30+
const (
31+
escapedStringsNoVarExpansionFmt = `([^"$\\]|\\[^$])*`
32+
escapedStringsNoVarExpansionErrMsg string = `a valid header must have all '"' escaped and must not contain any ` +
33+
`'$' or end with an unescaped '\'`
34+
)
35+
36+
var escapedStringsNoVarExpansionFmtRegexp = regexp.MustCompile("^" + escapedStringsNoVarExpansionFmt + "$")
37+
38+
// validateEscapedStringNoVarExpansion is the same as validateEscapedString except it doesn't allow $ to
39+
// prevent variable expansion.
40+
// If the value is invalid, the function returns an error that includes the specified examples of valid values.
41+
func validateEscapedStringNoVarExpansion(value string, examples []string) error {
42+
if !escapedStringsNoVarExpansionFmtRegexp.MatchString(value) {
43+
msg := k8svalidation.RegexError(escapedStringsNoVarExpansionErrMsg, escapedStringsNoVarExpansionFmt,
44+
examples...)
45+
return errors.New(msg)
46+
}
47+
return nil
48+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package validation
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestValidateEscapedString(t *testing.T) {
8+
validator := func(value string) error { return validateEscapedString(value, []string{"example"}) }
9+
10+
testValidValuesForSimpleValidator(t, validator,
11+
`test`,
12+
`test test`,
13+
`\"`,
14+
`\\`)
15+
testInvalidValuesForSimpleValidator(t, validator,
16+
`\`,
17+
`test"test`)
18+
}
19+
20+
func TestValidateEscapedStringNoVarExpansion(t *testing.T) {
21+
validator := func(value string) error { return validateEscapedStringNoVarExpansion(value, []string{"example"}) }
22+
23+
testValidValuesForSimpleValidator(t, validator,
24+
`test`,
25+
`test test`,
26+
`\"`,
27+
`\\`)
28+
testInvalidValuesForSimpleValidator(t, validator,
29+
`\`,
30+
`test"test`,
31+
`$test`)
32+
}
+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/*
2+
Package validation includes validators to validate values that will propagate to the NGINX configuration.
3+
4+
The validation rules prevent two cases:
5+
(1) Invalid values. Such values will cause NGINX to fail to reload the configuration.
6+
(2) Malicious values. Such values will cause NGINX to succeed to reload, but will configure NGINX maliciously, outside
7+
of the NKG capabilities. For example, configuring NGINX to serve the contents of the file system of its container.
8+
9+
The validation rules are based on the types in the parent config package and how they are used in the NGINX
10+
configuration templates. Changes to those might require changing the validation rules.
11+
12+
The rules are much looser for NGINX than for the Gateway API. However, some valid Gateway API values are not valid for
13+
NGINX.
14+
*/
15+
package validation

0 commit comments

Comments
 (0)