diff --git a/internal/configs/annotations.go b/internal/configs/annotations.go index 8e1f58ef85..69d024f0ab 100644 --- a/internal/configs/annotations.go +++ b/internal/configs/annotations.go @@ -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, @@ -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) diff --git a/internal/configs/annotations_test.go b/internal/configs/annotations_test.go index 1d612f5515..c72409ca90 100644 --- a/internal/configs/annotations_test.go +++ b/internal/configs/annotations_test.go @@ -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) + } + }) + } +} diff --git a/internal/configs/config_params.go b/internal/configs/config_params.go index 82e32944da..a477ad63be 100644 --- a/internal/configs/config_params.go +++ b/internal/configs/config_params.go @@ -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 diff --git a/internal/configs/ingress.go b/internal/configs/ingress.go index 0b4fc2d12b..54b5e0ffad 100644 --- a/internal/configs/ingress.go +++ b/internal/configs/ingress.go @@ -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) diff --git a/internal/configs/ingress_test.go b/internal/configs/ingress_test.go index 2e5ddb5f2e..cf051f6940 100644 --- a/internal/configs/ingress_test.go +++ b/internal/configs/ingress_test.go @@ -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() diff --git a/internal/configs/version1/config.go b/internal/configs/version1/config.go index 317157db97..fe8840245f 100644 --- a/internal/configs/version1/config.go +++ b/internal/configs/version1/config.go @@ -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. diff --git a/internal/configs/version1/nginx-plus.ingress.tmpl b/internal/configs/version1/nginx-plus.ingress.tmpl index 25b2049e5d..233680317c 100644 --- a/internal/configs/version1/nginx-plus.ingress.tmpl +++ b/internal/configs/version1/nginx-plus.ingress.tmpl @@ -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}} diff --git a/internal/configs/version1/nginx.ingress.tmpl b/internal/configs/version1/nginx.ingress.tmpl index 70d8f2cfa5..5c6d02c9bf 100644 --- a/internal/configs/version1/nginx.ingress.tmpl +++ b/internal/configs/version1/nginx.ingress.tmpl @@ -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}}"; diff --git a/internal/k8s/validation.go b/internal/k8s/validation.go index ef2274d746..58ee1d699d 100644 --- a/internal/k8s/validation.go +++ b/internal/k8s/validation.go @@ -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 ( @@ -360,6 +361,10 @@ var ( useClusterIPAnnotation: { validateBoolAnnotation, }, + appRootAnnotation: { + validateAppRootAnnotation, + validateRewriteTargetAnnotation, + }, } annotationNames = sortedAnnotationNames(annotationValidations) ) @@ -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{} diff --git a/internal/k8s/validation_test.go b/internal/k8s/validation_test.go index 7e7403c5ea..5452ea7ac6 100644 --- a/internal/k8s/validation_test.go +++ b/internal/k8s/validation_test.go @@ -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 { diff --git a/internal/telemetry/collector_test.go b/internal/telemetry/collector_test.go index d7fcee84f3..93e6be28a5 100644 --- a/internal/telemetry/collector_test.go +++ b/internal/telemetry/collector_test.go @@ -907,6 +907,36 @@ func TestInvalidStandardIngressAnnotations(t *testing.T) { } } +func TestAppRootAnnotationTelemetry(t *testing.T) { + t.Parallel() + + buf := &bytes.Buffer{} + exp := &telemetry.StdoutExporter{Endpoint: buf} + + annotations := map[string]string{ + "nginx.org/app-root": "/coffee", + } + + configurator := newConfiguratorWithIngressWithCustomAnnotations(t, annotations) + + cfg := telemetry.CollectorConfig{ + Configurator: configurator, + K8sClientReader: newTestClientset(node1, kubeNS), + Version: telemetryNICData.ProjectVersion, + } + + c, err := telemetry.NewCollector(cfg, telemetry.WithExporter(exp)) + if err != nil { + t.Fatal(err) + } + c.Collect(context.Background()) + + got := buf.String() + if !strings.Contains(got, "nginx.org/app-root") { + t.Errorf("expected app-root annotation to be collected in telemetry, got: %v", got) + } +} + func TestIngressCountReportsNumberOfDeployedIngresses(t *testing.T) { t.Parallel()