diff --git a/pkg/kube/translation/annotations.go b/pkg/kube/translation/annotations.go index 3f06b81340..434ff260d4 100644 --- a/pkg/kube/translation/annotations.go +++ b/pkg/kube/translation/annotations.go @@ -31,6 +31,7 @@ var ( annotations.NewForwardAuthHandler(), annotations.NewBasicAuthHandler(), annotations.NewKeyAuthHandler(), + annotations.NewCSRFHandler(), } ) diff --git a/pkg/kube/translation/annotations/csrf.go b/pkg/kube/translation/annotations/csrf.go new file mode 100644 index 0000000000..d3cc087b5c --- /dev/null +++ b/pkg/kube/translation/annotations/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 annotations + +import ( + apisixv1 "github.com/apache/apisix-ingress-controller/pkg/types/apisix/v1" +) + +const ( + _enableCsrf = AnnotationsPrefix + "enable-csrf" + _csrfKey = AnnotationsPrefix + "csrf-key" +) + +type csrf struct{} + +// NewCSRFHandler creates a handler to convert annotations about +// CSRF to APISIX csrf plugin. +func NewCSRFHandler() Handler { + return &csrf{} +} + +func (c *csrf) PluginName() string { + return "csrf" +} + +func (c *csrf) Handle(e Extractor) (interface{}, error) { + if !e.GetBoolAnnotation(_enableCsrf) { + return nil, nil + } + var plugin apisixv1.CSRFConfig + plugin.Key = e.GetStringAnnotation(_csrfKey) + if plugin.Key != "" { + return &plugin, nil + } + return nil, nil +} diff --git a/pkg/types/apisix/v1/plugin_types.go b/pkg/types/apisix/v1/plugin_types.go index 411062c941..5e97ce4261 100644 --- a/pkg/types/apisix/v1/plugin_types.go +++ b/pkg/types/apisix/v1/plugin_types.go @@ -49,6 +49,12 @@ type CorsConfig struct { AllowHeaders string `json:"allow_headers,omitempty"` } +// CSRfConfig is the rule config for csrf plugin. +// +k8s:deepcopy-gen=true +type CSRFConfig struct { + Key string `json:"key"` +} + // KeyAuthConsumerConfig is the rule config for key-auth plugin // used in Consumer object. // +k8s:deepcopy-gen=true diff --git a/pkg/types/apisix/v1/zz_generated.deepcopy.go b/pkg/types/apisix/v1/zz_generated.deepcopy.go index 4c04d91409..03a4f23427 100644 --- a/pkg/types/apisix/v1/zz_generated.deepcopy.go +++ b/pkg/types/apisix/v1/zz_generated.deepcopy.go @@ -67,6 +67,22 @@ func (in *BasicAuthRouteConfig) DeepCopy() *BasicAuthRouteConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CSRFConfig) DeepCopyInto(out *CSRFConfig) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CSRFConfig. +func (in *CSRFConfig) DeepCopy() *CSRFConfig { + if in == nil { + return nil + } + out := new(CSRFConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Consumer) DeepCopyInto(out *Consumer) { *out = *in diff --git a/test/e2e/suite-annotations/csrf.go b/test/e2e/suite-annotations/csrf.go new file mode 100644 index 0000000000..989310e13e --- /dev/null +++ b/test/e2e/suite-annotations/csrf.go @@ -0,0 +1,195 @@ +// 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 annotations + +import ( + "fmt" + "net/http" + "time" + + "github.com/onsi/ginkgo" + "github.com/stretchr/testify/assert" + + "github.com/apache/apisix-ingress-controller/test/e2e/scaffold" +) + +var _ = ginkgo.Describe("suite-annotations: csrf annotations", func() { + s := scaffold.NewDefaultScaffold() + + ginkgo.It("enable csrf in ingress networking/v1", func() { + backendSvc, backendPort := s.DefaultHTTPBackend() + ing := fmt.Sprintf(` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: apisix + k8s.apisix.apache.org/enable-csrf: "true" + k8s.apisix.apache.org/csrf-key: "foo-key" + name: ingress-v1 +spec: + rules: + - host: httpbin.org + http: + paths: + - path: /* + pathType: Prefix + backend: + service: + name: %s + port: + number: %d +`, backendSvc, backendPort[0]) + assert.Nil(ginkgo.GinkgoT(), s.CreateResourceFromString(ing), "creating ingress") + + time.Sleep(5 * time.Second) + + msg401 := s.NewAPISIXClient(). + POST("/anything"). + WithHeader("Host", "httpbin.org"). + Expect(). + Status(http.StatusUnauthorized). + Body(). + Raw() + assert.Contains(ginkgo.GinkgoT(), msg401, "no csrf token in headers") + + resp := s.NewAPISIXClient(). + GET("/anything"). + WithHeader("Host", "httpbin.org"). + Expect(). + Status(http.StatusOK) + resp.Header("Set-Cookie").NotEmpty() + + cookie := resp.Cookie("apisix-csrf-token") + token := cookie.Value().Raw() + + _ = s.NewAPISIXClient(). + POST("/anything"). + WithHeader("Host", "httpbin.org"). + WithHeader("apisix-csrf-token", token). + WithCookie("apisix-csrf-token", token). + Expect(). + Status(http.StatusOK) + + }) + + ginkgo.It("enable csrf in ingress networking/v1beta1", func() { + backendSvc, backendPort := s.DefaultHTTPBackend() + ing := fmt.Sprintf(` +apiVersion: networking.k8s.io/v1beta1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: apisix + k8s.apisix.apache.org/enable-csrf: "true" + k8s.apisix.apache.org/csrf-key: "foo-key" + name: ingress-v1beta1 +spec: + rules: + - host: httpbin.org + http: + paths: + - path: /* + pathType: Prefix + backend: + serviceName: %s + servicePort: %d +`, backendSvc, backendPort[0]) + assert.Nil(ginkgo.GinkgoT(), s.CreateResourceFromString(ing), "creating ingress") + + time.Sleep(5 * time.Second) + + msg401 := s.NewAPISIXClient(). + POST("/anything"). + WithHeader("Host", "httpbin.org"). + Expect(). + Status(http.StatusUnauthorized). + Body(). + Raw() + assert.Contains(ginkgo.GinkgoT(), msg401, "no csrf token in headers") + + resp := s.NewAPISIXClient(). + GET("/anything"). + WithHeader("Host", "httpbin.org"). + Expect(). + Status(http.StatusOK) + resp.Header("Set-Cookie").NotEmpty() + + cookie := resp.Cookie("apisix-csrf-token") + token := cookie.Value().Raw() + + _ = s.NewAPISIXClient(). + POST("/anything"). + WithHeader("Host", "httpbin.org"). + WithHeader("apisix-csrf-token", token). + WithCookie("apisix-csrf-token", token). + Expect(). + Status(http.StatusOK) + }) + + ginkgo.It("enable csrf in ingress extensions/v1beta1", func() { + backendSvc, backendPort := s.DefaultHTTPBackend() + ing := fmt.Sprintf(` +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: apisix + k8s.apisix.apache.org/enable-csrf: "true" + k8s.apisix.apache.org/csrf-key: "foo-key" + name: ingress-extensions-v1beta1 +spec: + rules: + - host: httpbin.org + http: + paths: + - path: /* + pathType: Prefix + backend: + serviceName: %s + servicePort: %d +`, backendSvc, backendPort[0]) + assert.Nil(ginkgo.GinkgoT(), s.CreateResourceFromString(ing), "creating ingress") + + time.Sleep(5 * time.Second) + + msg401 := s.NewAPISIXClient(). + POST("/anything"). + WithHeader("Host", "httpbin.org"). + Expect(). + Status(http.StatusUnauthorized). + Body(). + Raw() + assert.Contains(ginkgo.GinkgoT(), msg401, "no csrf token in headers") + + resp := s.NewAPISIXClient(). + GET("/anything"). + WithHeader("Host", "httpbin.org"). + Expect(). + Status(http.StatusOK) + resp.Header("Set-Cookie").NotEmpty() + + cookie := resp.Cookie("apisix-csrf-token") + token := cookie.Value().Raw() + + _ = s.NewAPISIXClient(). + POST("/anything"). + WithHeader("Host", "httpbin.org"). + WithHeader("apisix-csrf-token", token). + WithCookie("apisix-csrf-token", token). + Expect(). + Status(http.StatusOK) + }) +})