This is an automated email from the ASF dual-hosted git repository. ronething pushed a commit to branch feat/csrf in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git
commit ffe53367f045a1031ccaa77321e57adeaba78fce Author: Ashing Zheng <[email protected]> AuthorDate: Tue Oct 28 12:05:52 2025 +0800 feat: support csrf annotations for ingress Signed-off-by: Ashing Zheng <[email protected]> --- .../adc/translator/annotations/plugins/csrf.go | 48 +++++++++++++++ .../translator/annotations/plugins/csrf_test.go | 52 ++++++++++++++++ .../adc/translator/annotations/plugins/plugins.go | 1 + test/e2e/ingress/annotations.go | 70 ++++++++++++++++++++++ 4 files changed, 171 insertions(+) diff --git a/internal/adc/translator/annotations/plugins/csrf.go b/internal/adc/translator/annotations/plugins/csrf.go new file mode 100644 index 00000000..1bee407c --- /dev/null +++ b/internal/adc/translator/annotations/plugins/csrf.go @@ -0,0 +1,48 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plugins + +import ( + adctypes "github.com/apache/apisix-ingress-controller/api/adc" + "github.com/apache/apisix-ingress-controller/internal/adc/translator/annotations" +) + +type csrf struct{} + +// NewCSRFHandler creates a handler to convert annotations about +// CSRF to APISIX csrf plugin. +func NewCSRFHandler() PluginAnnotationsHandler { + return &csrf{} +} + +func (c *csrf) PluginName() string { + return "csrf" +} + +func (c *csrf) Handle(e annotations.Extractor) (any, error) { + if !e.GetBoolAnnotation(annotations.AnnotationsEnableCsrf) { + return nil, nil + } + + key := e.GetStringAnnotation(annotations.AnnotationsCsrfKey) + if key == "" { + return nil, nil + } + + return &adctypes.CSRFConfig{ + Key: key, + }, nil +} diff --git a/internal/adc/translator/annotations/plugins/csrf_test.go b/internal/adc/translator/annotations/plugins/csrf_test.go new file mode 100644 index 00000000..0f681703 --- /dev/null +++ b/internal/adc/translator/annotations/plugins/csrf_test.go @@ -0,0 +1,52 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plugins + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + adctypes "github.com/apache/apisix-ingress-controller/api/adc" + "github.com/apache/apisix-ingress-controller/internal/adc/translator/annotations" +) + +func TestCSRFHandler(t *testing.T) { + anno := map[string]string{ + annotations.AnnotationsEnableCsrf: "true", + annotations.AnnotationsCsrfKey: "my-secret-key", + } + p := NewCSRFHandler() + out, err := p.Handle(annotations.NewExtractor(anno)) + assert.Nil(t, err, "checking given error") + config := out.(*adctypes.CSRFConfig) + assert.Equal(t, "my-secret-key", config.Key) + + assert.Equal(t, "csrf", p.PluginName()) + + // Test with enable-csrf set to false + anno[annotations.AnnotationsEnableCsrf] = "false" + out, err = p.Handle(annotations.NewExtractor(anno)) + assert.Nil(t, err, "checking given error") + assert.Nil(t, out, "checking given output") + + // Test with enable-csrf true but no key + anno[annotations.AnnotationsEnableCsrf] = "true" + delete(anno, annotations.AnnotationsCsrfKey) + out, err = p.Handle(annotations.NewExtractor(anno)) + assert.Nil(t, err, "checking given error") + assert.Nil(t, out, "checking given output when key is missing") +} diff --git a/internal/adc/translator/annotations/plugins/plugins.go b/internal/adc/translator/annotations/plugins/plugins.go index ee1e6206..fb0f0b27 100644 --- a/internal/adc/translator/annotations/plugins/plugins.go +++ b/internal/adc/translator/annotations/plugins/plugins.go @@ -38,6 +38,7 @@ var ( handlers = []PluginAnnotationsHandler{ NewRedirectHandler(), NewCorsHandler(), + NewCSRFHandler(), } ) diff --git a/test/e2e/ingress/annotations.go b/test/e2e/ingress/annotations.go index 3128cb04..0bca460b 100644 --- a/test/e2e/ingress/annotations.go +++ b/test/e2e/ingress/annotations.go @@ -317,6 +317,28 @@ spec: name: httpbin-service-e2e-test port: number: 80 +` + ingressCSRF = ` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: csrf + annotations: + k8s.apisix.apache.org/enable-csrf: "true" + k8s.apisix.apache.org/csrf-key: "foo-key" +spec: + ingressClassName: %s + rules: + - host: httpbin.example + http: + paths: + - path: /anything + pathType: Prefix + backend: + service: + name: httpbin-service-e2e-test + port: + number: 80 ` ) BeforeEach(func() { @@ -359,5 +381,53 @@ spec: Status(http.StatusPermanentRedirect). Header("Location").IsEqual("/anything/ip") }) + + It("csrf", func() { + Expect(s.CreateResourceFromString(fmt.Sprintf(ingressCSRF, s.Namespace()))).ShouldNot(HaveOccurred(), "creating Ingress") + + time.Sleep(5 * time.Second) + // Verify CSRF plugin is configured in the route + routes, err := s.DefaultDataplaneResource().Route().List(context.Background()) + Expect(err).NotTo(HaveOccurred(), "listing Route") + Expect(routes).To(HaveLen(1), "checking Route length") + Expect(routes[0].Plugins).To(HaveKey("csrf"), "checking Route plugins") + jsonBytes, err := json.Marshal(routes[0].Plugins["csrf"]) + Expect(err).NotTo(HaveOccurred(), "marshalling csrf plugin config") + var csrfConfig map[string]any + err = json.Unmarshal(jsonBytes, &csrfConfig) + Expect(err).NotTo(HaveOccurred(), "unmarshalling csrf plugin config") + Expect(csrfConfig["key"]).To(Equal("foo-key"), "checking csrf key") + + // Request without CSRF token should fail + msg401 := s.NewAPISIXClient(). + POST("/anything"). + WithHeader("Host", "httpbin.example"). + Expect(). + Status(http.StatusUnauthorized). + Body(). + Raw() + Expect(msg401).To(ContainSubstring("no csrf token in headers"), "checking error message") + + // GET request should succeed and return CSRF token in cookie + resp := s.NewAPISIXClient(). + GET("/anything"). + WithHeader("Host", "httpbin.example"). + Expect(). + Status(http.StatusOK) + resp.Header("Set-Cookie").NotEmpty() + + cookie := resp.Cookie("apisix-csrf-token") + token := cookie.Value().Raw() + + // POST request with valid CSRF token should succeed + _ = s.NewAPISIXClient(). + POST("/anything"). + WithHeader("Host", "httpbin.example"). + WithHeader("apisix-csrf-token", token). + WithCookie("apisix-csrf-token", token). + Expect(). + Status(http.StatusOK) + + }) }) })
