Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement NKG-specific field validation for HTTPRoutes #455

Merged
merged 30 commits into from
Mar 16, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2f01d7a
Add NKG-specific field validation for HTTPRoutes
pleshakov Mar 7, 2023
cbd0e2a
Apply suggestions on GitHub
pleshakov Mar 13, 2023
b64c795
fmt.Errorf(string) -> errors.New(string)
pleshakov Mar 14, 2023
3e8004b
add a comment about the purpose of examples
pleshakov Mar 14, 2023
8c5283a
add type constraint
pleshakov Mar 14, 2023
0ba4d14
Place examples into variables
pleshakov Mar 14, 2023
63d5dfc
Make NJS stand out more
pleshakov Mar 14, 2023
8b2dace
test all valid http methods
pleshakov Mar 14, 2023
79c2c81
Implement TODO condition for partial validity
pleshakov Mar 14, 2023
d5f3e72
Make createBackend() return pointer instead of a slice
pleshakov Mar 14, 2023
9ef4bf4
Add comment for BackendGroup field of Rule
pleshakov Mar 14, 2023
c5e3460
Make Route invalid if all Rules are invalid
pleshakov Mar 14, 2023
1695f43
Improve name of buildRoutes
pleshakov Mar 14, 2023
5ef2ce2
keep the happy path aligned left in bindRouteToListeners
pleshakov Mar 14, 2023
2968f74
handle case 2 first in bindRouteToListeners
pleshakov Mar 14, 2023
f0ee31e
No need for multilines params in bindRoute(s)ToListener
pleshakov Mar 14, 2023
636c0e5
improve validateMethodMatch
pleshakov Mar 14, 2023
cacdeb2
Remove unnecessary route in tests
pleshakov Mar 14, 2023
cf2e566
unbound -> unattached
pleshakov Mar 14, 2023
976ab42
Add unattached section case to TestGetAllConditionsForSectionName
pleshakov Mar 14, 2023
57bd5a6
Add a case for explicit ground and kind in TestFindGatewayForParentRef
pleshakov Mar 14, 2023
0fb3f9d
Move helper test functions to the top
pleshakov Mar 14, 2023
bdbff1d
Nil -> Missing
pleshakov Mar 14, 2023
547c482
Test multiple valid hostnames
pleshakov Mar 14, 2023
12f1e40
change the weight of the normal ref to something besides the default …
pleshakov Mar 14, 2023
c9f5834
Add a link to a fixme about removing warnings
pleshakov Mar 14, 2023
3b5a532
Add a FIXME about refactoring BackendGroups
pleshakov Mar 14, 2023
2f6f83d
Fix linting
pleshakov Mar 14, 2023
c59d568
Add not found case and remove no-longer needed section name
pleshakov Mar 16, 2023
e08c130
Merge branch 'main' into feature/httproute-validation
pleshakov Mar 16, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions internal/helpers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import (
// Diff prints the diff between two structs.
// It is useful in testing to compare two structs when they are large. In such a case, without Diff it will be difficult
// to pinpoint the difference between the two structs.
func Diff(x, y interface{}) string {
r := cmp.Diff(x, y)
func Diff(want, got any) string {
r := cmp.Diff(want, got)

if r != "" {
return "(-want +got)\n" + r
Expand Down Expand Up @@ -57,3 +57,8 @@ func GetTLSModePointer(t v1beta1.TLSModeType) *v1beta1.TLSModeType {
func GetBoolPointer(b bool) *bool {
return &b
}

// GetPointer takes a value of any type and returns a pointer to it.
func GetPointer[T any](v T) *T {
return &v
}
11 changes: 8 additions & 3 deletions internal/manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,22 @@ import (
"sigs.k8s.io/controller-runtime/pkg/manager"
k8spredicate "sigs.k8s.io/controller-runtime/pkg/predicate"
gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1"
"sigs.k8s.io/gateway-api/apis/v1beta1/validation"
gwapivalidation "sigs.k8s.io/gateway-api/apis/v1beta1/validation"

"github.com/nginxinc/nginx-kubernetes-gateway/internal/config"
"github.com/nginxinc/nginx-kubernetes-gateway/internal/events"
"github.com/nginxinc/nginx-kubernetes-gateway/internal/manager/filter"
"github.com/nginxinc/nginx-kubernetes-gateway/internal/manager/index"
"github.com/nginxinc/nginx-kubernetes-gateway/internal/manager/predicate"
ngxcfg "github.com/nginxinc/nginx-kubernetes-gateway/internal/nginx/config"
ngxvalidation "github.com/nginxinc/nginx-kubernetes-gateway/internal/nginx/config/validation"
"github.com/nginxinc/nginx-kubernetes-gateway/internal/nginx/file"
ngxruntime "github.com/nginxinc/nginx-kubernetes-gateway/internal/nginx/runtime"
"github.com/nginxinc/nginx-kubernetes-gateway/internal/state"
"github.com/nginxinc/nginx-kubernetes-gateway/internal/state/relationship"
"github.com/nginxinc/nginx-kubernetes-gateway/internal/state/resolver"
"github.com/nginxinc/nginx-kubernetes-gateway/internal/state/secrets"
"github.com/nginxinc/nginx-kubernetes-gateway/internal/state/validation"
"github.com/nginxinc/nginx-kubernetes-gateway/internal/status"
)

Expand Down Expand Up @@ -80,13 +82,13 @@ func Start(cfg config.Config) error {
{
objectType: &gatewayv1beta1.Gateway{},
options: []controllerOption{
withWebhookValidator(createValidator(validation.ValidateGateway)),
withWebhookValidator(createValidator(gwapivalidation.ValidateGateway)),
},
},
{
objectType: &gatewayv1beta1.HTTPRoute{},
options: []controllerOption{
withWebhookValidator(createValidator(validation.ValidateHTTPRoute)),
withWebhookValidator(createValidator(gwapivalidation.ValidateHTTPRoute)),
},
},
{
Expand Down Expand Up @@ -129,6 +131,9 @@ func Start(cfg config.Config) error {
ServiceResolver: resolver.NewServiceResolverImpl(mgr.GetClient()),
RelationshipCapturer: relationship.NewCapturerImpl(),
Logger: cfg.Logger.WithName("changeProcessor"),
Validators: validation.Validators{
HTTPFieldsValidator: ngxvalidation.HTTPValidator{},
},
})

configGenerator := ngxcfg.NewGeneratorImpl()
Expand Down
4 changes: 4 additions & 0 deletions internal/nginx/config/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ func NewGeneratorImpl() GeneratorImpl {
// executeFunc is a function that generates NGINX configuration from internal representation.
type executeFunc func(configuration dataplane.Configuration) []byte

// Generate generates NGINX configuration from internal representation.
// It is the responsibility of the caller to validate the configuration before calling this function.
// In case of invalid configuration, NGINX will fail to reload or could be configured with malicious configuration.
// To validate, use the validators from the validation package.
func (g GeneratorImpl) Generate(conf dataplane.Configuration) []byte {
var generated []byte
for _, execute := range getExecuteFuncs() {
Expand Down
4 changes: 3 additions & 1 deletion internal/nginx/config/http/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type Location struct {

// Return represents an HTTP return.
type Return struct {
URL string
Body string
Code StatusCode
}

Expand All @@ -38,6 +38,8 @@ const (
StatusFound StatusCode = 302
// StatusNotFound is the HTTP 404 status code.
StatusNotFound StatusCode = 404
// StatusInternalServerError is the HTTP 500 status code.
StatusInternalServerError StatusCode = 500
)

// Upstream holds all configuration for an HTTP upstream.
Expand Down
27 changes: 15 additions & 12 deletions internal/nginx/config/servers.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import (

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

const rootPath = "/"
const (
// HeaderMatchSeparator is the separator for constructing header-based match for NJS.
HeaderMatchSeparator = ":"
rootPath = "/"
)

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

// FIXME(pleshakov): There could be a case when the filter has the type set but not the corresponding field.
if r.Filters.InvalidFilter != nil {
loc.Return = &http.Return{Code: http.StatusInternalServerError}
locs = append(locs, loc)
continue
}

// There could be a case when the filter has the type set but not the corresponding field.
// For example, type is v1beta1.HTTPRouteFilterRequestRedirect, but RequestRedirect field is nil.
// The validation webhook catches that.
// If it doesn't work as expected, such situation is silently handled below in findFirstFilters.
// Consider reporting an error. But that should be done in a separate validation layer.
// The imported Webhook validation webhook catches that.

// RequestRedirect and proxying are mutually exclusive.
if r.Filters.RequestRedirect != nil {
loc.Return = createReturnValForRedirectFilter(r.Filters.RequestRedirect, listenerPort)

locs = append(locs, loc)
continue
}
Expand Down Expand Up @@ -172,9 +179,6 @@ func createReturnValForRedirectFilter(filter *v1beta1.HTTPRequestRedirectFilter,
hostname = string(*filter.Hostname)
}

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

// FIXME(pleshakov): Same as the FIXME about StatusCode above.
scheme := "$scheme"
if filter.Scheme != nil {
scheme = *filter.Scheme
}

return &http.Return{
Code: code,
URL: fmt.Sprintf("%s://%s:%d$request_uri", scheme, hostname, port),
Body: fmt.Sprintf("%s://%s:%d$request_uri", scheme, hostname, port),
}
}

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

func isPathOnlyMatch(match v1beta1.HTTPRouteMatch) bool {
Expand Down
2 changes: 1 addition & 1 deletion internal/nginx/config/servers_template.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ server {
{{ end }}

{{ if $l.Return }}
return {{ $l.Return.Code }} {{ $l.Return.URL }};
return {{ $l.Return.Code }} "{{ $l.Return.Body }}";
kate-osborn marked this conversation as resolved.
Show resolved Hide resolved
{{ end }}

{{ if $l.HTTPMatchVar }}
Expand Down
47 changes: 39 additions & 8 deletions internal/nginx/config/servers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,16 @@ func TestCreateServers(t *testing.T) {
},
// redirect is set in the corresponding state.MatchRule
},
{
// A match with an invalid filter
Matches: []v1beta1.HTTPRouteMatch{
{
Path: &v1beta1.HTTPPathMatch{
Value: helpers.GetPointer("/invalid-filter"),
},
},
},
},
},
},
}
Expand Down Expand Up @@ -324,6 +334,8 @@ func TestCreateServers(t *testing.T) {

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

invalidFilterGroup := graph.BackendGroup{Source: hrNsName, RuleIdx: 5}

cafePathRules := []dataplane.PathRule{
{
Path: "/",
Expand Down Expand Up @@ -403,6 +415,20 @@ func TestCreateServers(t *testing.T) {
},
},
},
{
Path: "/invalid-filter",
MatchRules: []dataplane.MatchRule{
{
MatchIdx: 0,
RuleIdx: 5,
Source: hr,
Filters: dataplane.Filters{
InvalidFilter: &dataplane.InvalidFilter{},
},
BackendGroup: invalidFilterGroup,
},
},
},
}

httpServers := []dataplane.VirtualServer{
Expand Down Expand Up @@ -491,14 +517,20 @@ func TestCreateServers(t *testing.T) {
Path: "/redirect-implicit-port",
Return: &http.Return{
Code: 302,
URL: fmt.Sprintf("$scheme://foo.example.com:%d$request_uri", port),
Body: fmt.Sprintf("$scheme://foo.example.com:%d$request_uri", port),
},
},
{
Path: "/redirect-explicit-port",
Return: &http.Return{
Code: 302,
URL: "$scheme://bar.example.com:8080$request_uri",
Body: "$scheme://bar.example.com:8080$request_uri",
},
},
{
Path: "/invalid-filter",
Return: &http.Return{
Code: http.StatusInternalServerError,
},
},
}
Expand All @@ -522,11 +554,10 @@ func TestCreateServers(t *testing.T) {
},
}

result := createServers(httpServers, sslServers)
g := NewGomegaWithT(t)

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

func TestCreateLocationsRootPath(t *testing.T) {
Expand Down Expand Up @@ -713,7 +744,7 @@ func TestCreateReturnValForRedirectFilter(t *testing.T) {
filter: &v1beta1.HTTPRequestRedirectFilter{},
expected: &http.Return{
Code: http.StatusFound,
URL: "$scheme://$host:123$request_uri",
Body: "$scheme://$host:123$request_uri",
},
msg: "all fields are empty",
},
Expand All @@ -726,7 +757,7 @@ func TestCreateReturnValForRedirectFilter(t *testing.T) {
},
expected: &http.Return{
Code: 101,
URL: "https://foo.example.com:2022$request_uri",
Body: "https://foo.example.com:2022$request_uri",
},
msg: "all fields are set",
},
Expand Down
46 changes: 46 additions & 0 deletions internal/nginx/config/validation/common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package validation
kate-osborn marked this conversation as resolved.
Show resolved Hide resolved

import (
"fmt"
"regexp"

k8svalidation "k8s.io/apimachinery/pkg/util/validation"
)

const (
escapedStringsFmt = `([^"\\]|\\.)*`
escapedStringsErrMsg = `must have all '"' (double quotes) escaped and must not end with an unescaped '\' ` +
`(backslash)`
)

var escapedStringsFmtRegexp = regexp.MustCompile("^" + escapedStringsFmt + "$")

// validateEscapedString is used to validate a string that is surrounded by " in the NGINX config for a directive
// that doesn't support any regex rules or variables (it doesn't try to expand the variable name behind $).
// For example, server_name "hello $not_a_var world"
func validateEscapedString(value string, examples ...string) error {
kate-osborn marked this conversation as resolved.
Show resolved Hide resolved
if !escapedStringsFmtRegexp.MatchString(value) {
msg := k8svalidation.RegexError(escapedStringsErrMsg, escapedStringsFmt, examples...)
return fmt.Errorf(msg)
pleshakov marked this conversation as resolved.
Show resolved Hide resolved
}
return nil
}

const (
escapedStringsNoVarExpansionFmt = `([^"$\\]|\\[^$])*`
escapedStringsNoVarExpansionErrMsg string = `a valid header must have all '"' escaped and must not contain any ` +
`'$' or end with an unescaped '\'`
)

var escapedStringsNoVarExpansionFmtRegexp = regexp.MustCompile("^" + escapedStringsNoVarExpansionFmt + "$")

// validateEscapedStringNoVarExpansion is the same as validateEscapedString except it doesn't allow $ to
// prevent variable expansion.
func validateEscapedStringNoVarExpansion(value string, examples ...string) error {
if !escapedStringsNoVarExpansionFmtRegexp.MatchString(value) {
msg := k8svalidation.RegexError(escapedStringsNoVarExpansionErrMsg, escapedStringsNoVarExpansionFmt,
examples...)
return fmt.Errorf(msg)
kate-osborn marked this conversation as resolved.
Show resolved Hide resolved
}
return nil
}
32 changes: 32 additions & 0 deletions internal/nginx/config/validation/common_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package validation

import (
"testing"
)

func TestValidateEscapedString(t *testing.T) {
validator := func(value string) error { return validateEscapedString(value, "example") }

testValidValuesForSimpleValidator(t, validator,
`test`,
`test test`,
`\"`,
`\\`)
testInvalidValuesForSimpleValidator(t, validator,
`\`,
`test"test`)
}

func TestValidateEscapedStringNoVarExpansion(t *testing.T) {
validator := func(value string) error { return validateEscapedStringNoVarExpansion(value, "example") }

testValidValuesForSimpleValidator(t, validator,
`test`,
`test test`,
`\"`,
`\\`)
testInvalidValuesForSimpleValidator(t, validator,
`\`,
`test"test`,
`$test`)
}
15 changes: 15 additions & 0 deletions internal/nginx/config/validation/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
Package validation includes validators to validate values that will propagate to the NGINX configuration.

The validation rules prevent two cases:
(1) Invalid values. Such values will cause NGINX to fail to reload the configuration.
(2) Malicious values. Such values will cause NGINX to succeed to reload, but will configure NGINX maliciously, outside
of the NKG capabilities. For example, configuring NGINX to serve the contents of the file system of its container.

The validation rules are based on the types in the parent config package and how they are used in the NGINX
configuration templates. Changes to those might require changing the validation rules.

The rules are much looser for NGINX than for the Gateway API. However, some valid Gateway API values are not valid for
NGINX.
*/
package validation
Loading