From 2b6964cbd7ff7b7a595ff903e0d0fd8ec309cf86 Mon Sep 17 00:00:00 2001 From: Shreemaan Abhishek Date: Sun, 7 Dec 2025 16:24:18 +0545 Subject: [PATCH 1/2] feat: implicitly allow options method on routes with CORS security policy Signed-off-by: Shreemaan Abhishek --- internal/gatewayapi/securitypolicy.go | 99 +++++++ .../securitypolicy-implicit-options.in.yaml | 52 ++++ .../securitypolicy-implicit-options.out.yaml | 268 ++++++++++++++++++ 3 files changed, 419 insertions(+) create mode 100644 internal/gatewayapi/testdata/securitypolicy-implicit-options.in.yaml create mode 100644 internal/gatewayapi/testdata/securitypolicy-implicit-options.out.yaml diff --git a/internal/gatewayapi/securitypolicy.go b/internal/gatewayapi/securitypolicy.go index dc4f2db0303..d23652c904b 100644 --- a/internal/gatewayapi/securitypolicy.go +++ b/internal/gatewayapi/securitypolicy.go @@ -368,6 +368,20 @@ func (t *Translator) processSecurityPolicyForGateway( policy.Generation, ) } + + // append the preflight routes to the listener + for _, listener := range targetedGateway.listeners { + // If the target is a gateway and the section name is defined, then the policy should only apply to the listener + if currTarget.SectionName != nil && string(*currTarget.SectionName) != string(listener.Name) { + continue + } + + irListener := xdsIR[t.getIRKey(targetedGateway.Gateway)].GetHTTPListener(irListenerName(listener)) + if irListener != nil && irListener.Routes != nil { + // Prefix is empty because invalid prefix means apply to all routes + t.processCORSPreflight(irListener, "", policy.Spec.CORS) + } + } } // validateSecurityPolicy validates the SecurityPolicy. @@ -764,6 +778,7 @@ func (t *Translator) translateSecurityPolicyForRoute( } } } + t.processCORSPreflight(irListener, prefix, policy.Spec.CORS) } } } @@ -775,6 +790,7 @@ func (t *Translator) translateSecurityPolicyForRoute( "error", errs, ) } + return errs } @@ -2019,3 +2035,86 @@ func defaultAuthorizationRuleName(policy *egv1a1.SecurityPolicy, index int) stri irConfigName(policy), strconv.Itoa(index)) } + +func (t *Translator) processCORSPreflight(irListener *ir.HTTPListener, prefix string, cors *egv1a1.CORS) { + if cors == nil { + return + } + + var preflightRoutes []*ir.HTTPRoute + for _, r := range irListener.Routes { + // If the prefix is empty, it means the policy applies to all routes (Gateway target) + // If the prefix is not empty, it means the policy applies to a specific route (Route target) + if prefix != "" && !strings.HasPrefix(r.Name, prefix) { + continue + } + + // Check if the route needs preflight + needsPreflight := false + for _, m := range r.HeaderMatches { + if m.Name == ":method" && m.Exact != nil && *m.Exact != "OPTIONS" { + needsPreflight = true + break + } + } + + if needsPreflight { + // Check if a preflight route already exists for this route + preflightName := r.Name + "-cors-preflight" + exists := false + for _, existing := range irListener.Routes { + if existing.Name == preflightName { + exists = true + break + } + } + if exists { + continue + } + + // Create preflight route + preRoute := &ir.HTTPRoute{ + Name: preflightName, + PathMatch: r.PathMatch, + QueryParamMatches: r.QueryParamMatches, + Destination: r.Destination, + Metadata: r.Metadata, + Security: &ir.SecurityFeatures{ + CORS: t.buildCORS(cors), + }, + } + + // Create header matches: + // copy original headers (excluding :method) + add CORS headers (:method=OPTIONS, origin, access-control-request-method) + headerMatches := make([]*ir.StringMatch, 0, len(r.HeaderMatches)+2) + for _, headerMatch := range r.HeaderMatches { + // Skip the original method match for CORS preflight route to avoid conflicting method requirements. + if headerMatch.Name == ":method" { + continue + } + headerMatches = append(headerMatches, headerMatch) + } + + corsHeaders := []*ir.StringMatch{ + { + Name: ":method", + Exact: ptr.To("OPTIONS"), + }, + { + Name: "origin", + SafeRegex: ptr.To(".*"), + }, + { + Name: "access-control-request-method", + SafeRegex: ptr.To(".*"), + }, + } + headerMatches = append(headerMatches, corsHeaders...) + preRoute.HeaderMatches = headerMatches + + preflightRoutes = append(preflightRoutes, preRoute) + } + } + + irListener.Routes = append(irListener.Routes, preflightRoutes...) +} diff --git a/internal/gatewayapi/testdata/securitypolicy-implicit-options.in.yaml b/internal/gatewayapi/testdata/securitypolicy-implicit-options.in.yaml new file mode 100644 index 00000000000..5a393ceb325 --- /dev/null +++ b/internal/gatewayapi/testdata/securitypolicy-implicit-options.in.yaml @@ -0,0 +1,52 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + namespace: envoy-gateway + name: gateway-1 + spec: + gatewayClassName: envoy-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: All +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-1 + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/" + method: GET + backendRefs: + - name: service-1 + port: 8080 +securityPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + namespace: default + name: policy-for-route-1 + spec: + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + cors: + allowOrigins: + - "https://*.test.com:8080" + allowMethods: + - GET diff --git a/internal/gatewayapi/testdata/securitypolicy-implicit-options.out.yaml b/internal/gatewayapi/testdata/securitypolicy-implicit-options.out.yaml new file mode 100644 index 00000000000..76ff2a53c6b --- /dev/null +++ b/internal/gatewayapi/testdata/securitypolicy-implicit-options.out.yaml @@ -0,0 +1,268 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + name: gateway-1 + namespace: envoy-gateway + spec: + gatewayClassName: envoy-gateway-class + listeners: + - allowedRoutes: + namespaces: + from: All + name: http + port: 80 + protocol: HTTP + status: + listeners: + - attachedRoutes: 1 + conditions: + - lastTransitionTime: null + message: Sending translated listener configuration to the data plane + reason: Programmed + status: "True" + type: Programmed + - lastTransitionTime: null + message: Listener has been successfully translated + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Listener references have been resolved + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: httproute-1 + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + matches: + - method: GET + path: + value: / + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +infraIR: + envoy-gateway/gateway-1: + proxy: + listeners: + - address: null + name: envoy-gateway/gateway-1/http + ports: + - containerPort: 10080 + name: http-80 + protocol: HTTP + servicePort: 80 + metadata: + labels: + gateway.envoyproxy.io/owning-gateway-name: gateway-1 + gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway + ownerReference: + kind: GatewayClass + name: envoy-gateway-class + name: envoy-gateway/gateway-1 + namespace: envoy-gateway-system +securityPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: SecurityPolicy + metadata: + name: policy-for-route-1 + namespace: default + spec: + cors: + allowMethods: + - GET + allowOrigins: + - https://*.test.com:8080 + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + conditions: + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + controllerName: gateway.envoyproxy.io/gatewayclass-controller +xdsIR: + envoy-gateway/gateway-1: + accessLog: + json: + - path: /dev/stdout + globalResources: + proxyServiceCluster: + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + settings: + - addressType: IP + endpoints: + - host: 7.6.5.4 + port: 8080 + zone: zone1 + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + protocol: TCP + http: + - address: 0.0.0.0 + externalPort: 80 + hostnames: + - '*' + isHTTP2: false + metadata: + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + name: envoy-gateway/gateway-1/http + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 10080 + routes: + - destination: + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + metadata: + kind: Service + name: service-1 + namespace: default + sectionName: "8080" + name: httproute/default/httproute-1/rule/0/backend/0 + protocol: HTTP + weight: 1 + headerMatches: + - distinct: false + exact: OPTIONS + name: :method + - distinct: false + name: origin + safeRegex: .* + - distinct: false + name: access-control-request-method + safeRegex: .* + hostname: "" + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0/match/0/gateway_envoyproxy_io-cors-preflight + pathMatch: + distinct: false + name: "" + prefix: / + security: + cors: + allowMethods: + - GET + allowOrigins: + - distinct: false + name: "" + safeRegex: https://.*\.test\.com:8080 + - destination: + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + metadata: + kind: Service + name: service-1 + namespace: default + sectionName: "8080" + name: httproute/default/httproute-1/rule/0/backend/0 + protocol: HTTP + weight: 1 + headerMatches: + - distinct: false + exact: GET + name: :method + hostname: gateway.envoyproxy.io + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0/match/0/gateway_envoyproxy_io + pathMatch: + distinct: false + name: "" + prefix: / + security: + cors: + allowMethods: + - GET + allowOrigins: + - distinct: false + name: "" + safeRegex: https://.*\.test\.com:8080 + readyListener: + address: 0.0.0.0 + ipFamily: IPv4 + path: /ready + port: 19003 From 9f99439b92a9cbc581fd7047a29476bdcd81655f Mon Sep 17 00:00:00 2001 From: Shreemaan Abhishek Date: Wed, 10 Dec 2025 01:37:59 +0000 Subject: [PATCH 2/2] deepcopy Signed-off-by: Shreemaan Abhishek --- internal/gatewayapi/securitypolicy.go | 14 ++++---------- test/e2e/testdata/cors-security-policy.yaml | 2 ++ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/internal/gatewayapi/securitypolicy.go b/internal/gatewayapi/securitypolicy.go index d23652c904b..85d5b04a210 100644 --- a/internal/gatewayapi/securitypolicy.go +++ b/internal/gatewayapi/securitypolicy.go @@ -2072,16 +2072,10 @@ func (t *Translator) processCORSPreflight(irListener *ir.HTTPListener, prefix st continue } - // Create preflight route - preRoute := &ir.HTTPRoute{ - Name: preflightName, - PathMatch: r.PathMatch, - QueryParamMatches: r.QueryParamMatches, - Destination: r.Destination, - Metadata: r.Metadata, - Security: &ir.SecurityFeatures{ - CORS: t.buildCORS(cors), - }, + preRoute := r.DeepCopy() + preRoute.Name = preflightName + preRoute.Security = &ir.SecurityFeatures{ + CORS: t.buildCORS(cors), } // Create header matches: diff --git a/test/e2e/testdata/cors-security-policy.yaml b/test/e2e/testdata/cors-security-policy.yaml index 3875223a961..4ac9ec9a57e 100644 --- a/test/e2e/testdata/cors-security-policy.yaml +++ b/test/e2e/testdata/cors-security-policy.yaml @@ -11,6 +11,7 @@ spec: - path: type: PathPrefix value: /cors-exact + method: GET backendRefs: - name: infra-backend-v1 port: 8080 @@ -57,6 +58,7 @@ spec: - path: type: PathPrefix value: /cors-wildcard + method: GET backendRefs: - name: infra-backend-v1 port: 8080