Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions internal/configs/annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ var minionDenylist = map[string]bool{
"nginx.org/server-snippets": true,
"nginx.org/ssl-ciphers": true,
"nginx.org/ssl-prefer-server-ciphers": true,
"nginx.org/app-root": true,
"appprotect.f5.com/app_protect_enable": true,
"appprotect.f5.com/app_protect_policy": true,
"appprotect.f5.com/app_protect_security_log_enable": true,
Expand Down Expand Up @@ -503,6 +504,10 @@ func parseAnnotations(ingEx *IngressEx, baseCfgParams *ConfigParams, isPlus bool
}
}

if appRoot, exists := ingEx.Ingress.Annotations["nginx.org/app-root"]; exists {
cfgParams.AppRoot = appRoot
}

if useClusterIP, exists, err := GetMapKeyAsBool(ingEx.Ingress.Annotations, UseClusterIPAnnotation, ingEx.Ingress); exists {
if err != nil {
nl.Error(l, err)
Expand Down
110 changes: 110 additions & 0 deletions internal/configs/annotations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1011,3 +1011,113 @@ func TestSSLRedirectAnnotations(t *testing.T) {
})
}
}

func TestAppRootAnnotation(t *testing.T) {
t.Parallel()

tests := []struct {
name string
annotations map[string]string
expected string
}{
{
name: "valid app-root - coffee path",
annotations: map[string]string{
"nginx.org/app-root": "/coffee",
},
expected: "/coffee",
},
{
name: "valid app-root - nested path with mocha",
annotations: map[string]string{
"nginx.org/app-root": "/coffee/mocha",
},
expected: "/coffee/mocha",
},
{
name: "valid app-root - tea path",
annotations: map[string]string{
"nginx.org/app-root": "/tea",
},
expected: "/tea",
},
{
name: "valid app-root - nested tea path",
annotations: map[string]string{
"nginx.org/app-root": "/tea/green-tea",
},
expected: "/tea/green-tea",
},
{
name: "valid app-root - cafe path",
annotations: map[string]string{
"nginx.org/app-root": "/cafe",
},
expected: "/cafe",
},
{
name: "invalid app-root - does not start with slash",
annotations: map[string]string{
"nginx.org/app-root": "coffee",
},
expected: "", // Should remain empty due to invalid path
},
{
name: "invalid app-root - contains invalid characters",
annotations: map[string]string{
"nginx.org/app-root": "/tea$mocha",
},
expected: "", // Should remain empty due to invalid characters
},
{
name: "invalid app-root - contains curly braces",
annotations: map[string]string{
"nginx.org/app-root": "/coffee{test}",
},
expected: "", // Should remain empty due to invalid characters
},
{
name: "invalid app-root - contains semicolon",
annotations: map[string]string{
"nginx.org/app-root": "/tea;chai",
},
expected: "", // Should remain empty due to invalid characters
},
{
name: "invalid app-root - contains whitespace",
annotations: map[string]string{
"nginx.org/app-root": "/tea chai",
},
expected: "", // Should remain empty due to invalid characters
},
{
name: "no app-root annotation",
annotations: map[string]string{},
expected: "", // Should remain empty when annotation is missing
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

ingEx := &IngressEx{
Ingress: &networking.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: "test-ingress",
Namespace: "default",
Annotations: tt.annotations,
},
},
}

baseCfgParams := NewDefaultConfigParams(context.Background(), false)
result := parseAnnotations(ingEx, baseCfgParams, false, false, false, false, false)

if result.AppRoot != tt.expected {
t.Errorf("Test %q: expected AppRoot %q, got %q", tt.name, tt.expected, result.AppRoot)
}
})
}
}
1 change: 1 addition & 0 deletions internal/configs/config_params.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
// ConfigParams holds NGINX configuration parameters that affect the main NGINX config
// as well as configs for Ingress resources.
type ConfigParams struct {
AppRoot string
Context context.Context
ClientMaxBodySize string
ClientBodyBufferSize string
Expand Down
1 change: 1 addition & 0 deletions internal/configs/ingress.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ func generateNginxCfg(p NginxCfgParams) (version1.IngressNginxConfig, Warnings)
AppProtectLogEnable: cfgParams.AppProtectLogEnable,
SpiffeCerts: cfgParams.SpiffeServerCerts,
DisableIPV6: p.staticParams.DisableIPV6,
AppRoot: cfgParams.AppRoot,
}

warnings := addSSLConfig(&server, p.ingEx.Ingress, rule.Host, p.ingEx.Ingress.Spec.TLS, p.ingEx.SecretRefs, p.isWildcardEnabled)
Expand Down
31 changes: 31 additions & 0 deletions internal/configs/ingress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,37 @@ func TestGenerateNginxCfgForBasicAuth(t *testing.T) {
}
}

func TestGenerateNginxCfgForAppRoot(t *testing.T) {
t.Parallel()
cafeIngressEx := createCafeIngressEx()
cafeIngressEx.Ingress.Annotations["nginx.org/app-root"] = "/coffee"

isPlus := false
configParams := NewDefaultConfigParams(context.Background(), isPlus)

expected := createExpectedConfigForCafeIngressEx(isPlus)
expected.Servers[0].AppRoot = "/coffee"

result, warnings := generateNginxCfg(NginxCfgParams{
staticParams: &StaticConfigParams{},
ingEx: &cafeIngressEx,
apResources: nil,
dosResource: nil,
isMinion: false,
isPlus: isPlus,
BaseCfgParams: configParams,
isResolverConfigured: false,
isWildcardEnabled: false,
})

if result.Servers[0].AppRoot != expected.Servers[0].AppRoot {
t.Errorf("generateNginxCfg returned AppRoot %v, but expected %v", result.Servers[0].AppRoot, expected.Servers[0].AppRoot)
}
if len(warnings) != 0 {
t.Errorf("generateNginxCfg returned warnings: %v", warnings)
}
}

func TestGenerateNginxCfgWithMissingTLSSecret(t *testing.T) {
t.Parallel()
cafeIngressEx := createCafeIngressEx()
Expand Down
2 changes: 2 additions & 0 deletions internal/configs/version1/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ type Server struct {
SpiffeCerts bool

DisableIPV6 bool

AppRoot string
}

// JWTRedirectLocation describes a location for redirecting client requests to a login URL for JWT Authentication.
Expand Down
6 changes: 6 additions & 0 deletions internal/configs/version1/nginx-plus.ingress.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ server {
{{$value}}{{end}}
{{- end}}

{{- if $server.AppRoot }}
if ($uri = /) {
return 302 $scheme://$http_host{{ $server.AppRoot }};
}
{{- end }}

{{- range $healthCheck := $server.HealthChecks}}
location @hc-{{$healthCheck.UpstreamName}} {
{{- range $name, $header := $healthCheck.Headers}}
Expand Down
6 changes: 6 additions & 0 deletions internal/configs/version1/nginx.ingress.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ server {
{{- range $value := $server.ServerSnippets}}
{{$value}}{{- end}}

{{- if $server.AppRoot }}
if ($uri = /) {
return 302 $scheme://$http_host{{ $server.AppRoot }};
}
{{- end }}

{{- range $location := $server.Locations}}
location {{ makeLocationPath $location $.Ingress.Annotations | printf }} {
set $service "{{$location.ServiceName}}";
Expand Down
36 changes: 36 additions & 0 deletions internal/k8s/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const (
stickyCookieServicesAnnotation = "nginx.com/sticky-cookie-services"
pathRegexAnnotation = "nginx.org/path-regex"
useClusterIPAnnotation = "nginx.org/use-cluster-ip"
appRootAnnotation = "nginx.org/app-root"
)

const (
Expand Down Expand Up @@ -360,6 +361,10 @@ var (
useClusterIPAnnotation: {
validateBoolAnnotation,
},
appRootAnnotation: {
validateAppRootAnnotation,
validateRewriteTargetAnnotation,
},
}
annotationNames = sortedAnnotationNames(annotationValidations)
)
Expand All @@ -373,6 +378,37 @@ func validatePathRegex(context *annotationValidationContext) field.ErrorList {
}
}

func validateAppRootAnnotation(context *annotationValidationContext) field.ErrorList {
allErrs := field.ErrorList{}

path := context.value

// App root must start with /
if !strings.HasPrefix(path, "/") {
allErrs = append(allErrs, field.Invalid(context.fieldPath, path, "must start with '/'"))
return allErrs
}

// App root cannot be just "/"
if path == "/" {
allErrs = append(allErrs, field.Invalid(context.fieldPath, path, "cannot be '/'"))
return allErrs
}

validPath := regexp.MustCompile(`^/[a-zA-Z0-9\-_./]*$`)
if !validPath.MatchString(path) {
allErrs = append(allErrs, field.Invalid(context.fieldPath, path, "contains invalid characters, only alphanumeric, hyphens, underscores, dots, and forward slashes are allowed"))
}

// Ensure path doesn't end with /
if strings.HasSuffix(path, "/") {
allErrs = append(allErrs, field.Invalid(context.fieldPath, path, "path should not end with '/'"))
return allErrs
}

return allErrs
}

func validateJWTLoginURLAnnotation(context *annotationValidationContext) field.ErrorList {
allErrs := field.ErrorList{}

Expand Down
80 changes: 80 additions & 0 deletions internal/k8s/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3465,6 +3465,86 @@ func TestValidateNginxIngressAnnotations(t *testing.T) {
},
msg: "invalid nginx.org/rewrite-target annotation, pipe character for alternatives",
},
{
annotations: map[string]string{
"nginx.org/app-root": "/coffee",
},
specServices: map[string]bool{},
isPlus: false,
appProtectEnabled: false,
appProtectDosEnabled: false,
internalRoutesEnabled: false,
expectedErrors: nil,
msg: "valid nginx.org/app-root annotation",
},
{
annotations: map[string]string{
"nginx.org/app-root": "/coffee/mocha",
},
specServices: map[string]bool{},
isPlus: false,
appProtectEnabled: false,
appProtectDosEnabled: false,
internalRoutesEnabled: false,
expectedErrors: nil,
msg: "valid nginx.org/app-root annotation with nested path",
},
{
annotations: map[string]string{
"nginx.org/app-root": "coffee",
},
specServices: map[string]bool{},
isPlus: false,
appProtectEnabled: false,
appProtectDosEnabled: false,
internalRoutesEnabled: false,
expectedErrors: []string{
`annotations.nginx.org/app-root: Invalid value: "coffee": must start with '/'`,
},
msg: "invalid nginx.org/app-root annotation, does not start with slash",
},
{
annotations: map[string]string{
"nginx.org/app-root": "/",
},
specServices: map[string]bool{},
isPlus: false,
appProtectEnabled: false,
appProtectDosEnabled: false,
internalRoutesEnabled: false,
expectedErrors: []string{
`annotations.nginx.org/app-root: Invalid value: "/": cannot be '/'`,
},
msg: "invalid nginx.org/app-root annotation, cannot be root path",
},
{
annotations: map[string]string{
"nginx.org/app-root": "/coffee/",
},
specServices: map[string]bool{},
isPlus: false,
appProtectEnabled: false,
appProtectDosEnabled: false,
internalRoutesEnabled: false,
expectedErrors: []string{
`annotations.nginx.org/app-root: Invalid value: "/coffee/": path should not end with '/'`,
},
msg: "invalid nginx.org/app-root annotation, cannot end with slash",
},
{
annotations: map[string]string{
"nginx.org/app-root": "/tea$1",
},
specServices: map[string]bool{},
isPlus: false,
appProtectEnabled: false,
appProtectDosEnabled: false,
internalRoutesEnabled: false,
expectedErrors: []string{
`annotations.nginx.org/app-root: Invalid value: "/tea$1": path must start with '/' and must not include any special character, '{', '}', ';' or '$'`,
},
msg: "invalid nginx.org/app-root annotation, invalid characters",
},
}

for _, test := range tests {
Expand Down
Loading
Loading