diff --git a/config/config-contour.yaml b/config/config-contour.yaml index 7a0ac21a0..553ce7375 100644 --- a/config/config-contour.yaml +++ b/config/config-contour.yaml @@ -54,3 +54,19 @@ data: ClusterLocal: class: contour-internal service: contour-internal/envoy + # cors-policy contains the configuration to set CORS policy for HTTPProxies. + cors-policy: | + allowCredentials: true + allowOrigin: + - example.com + allowMethods: + - GET + - POST + - OPTIONS + allowHeaders: + - authorization + - cache-control + exposeHeaders: + - Content-Length + - Content-Range + maxAge: "10m" diff --git a/pkg/reconciler/contour/config/contour.go b/pkg/reconciler/contour/config/contour.go index 443729fcc..e27fc0f27 100644 --- a/pkg/reconciler/contour/config/contour.go +++ b/pkg/reconciler/contour/config/contour.go @@ -18,8 +18,10 @@ package config import ( "fmt" + "regexp" "time" + v1 "github.com/projectcontour/contour/apis/projectcontour/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" @@ -39,6 +41,7 @@ const ( defaultTLSSecretConfigKey = "default-tls-secret" timeoutPolicyIdleKey = "timeout-policy-idle" timeoutPolicyResponseKey = "timeout-policy-response" + corsPolicy = "cors-policy" ) // Contour contains contour related configuration defined in the @@ -49,6 +52,7 @@ type Contour struct { DefaultTLSSecret *types.NamespacedName TimeoutPolicyResponse string TimeoutPolicyIdle string + CORSPolicy *v1.CORSPolicy } type visibilityValue struct { @@ -56,11 +60,12 @@ type visibilityValue struct { Service string `json:"service"` } -// NewContourFromConfigMap creates an Contour config from the supplied ConfigMap +// NewContourFromConfigMap creates a Contour config from the supplied ConfigMap func NewContourFromConfigMap(configMap *corev1.ConfigMap) (*Contour, error) { var tlsSecret *types.NamespacedName var timeoutPolicyResponse = "infinity" var timeoutPolicyIdle = "infinity" + var contourCORSPolicy *v1.CORSPolicy if err := configmap.Parse(configMap.Data, configmap.AsOptionalNamespacedName(defaultTLSSecretConfigKey, &tlsSecret), @@ -70,6 +75,41 @@ func NewContourFromConfigMap(configMap *corev1.ConfigMap) (*Contour, error) { return nil, err } + cors, ok := configMap.Data[corsPolicy] + if ok { + if err := yaml.Unmarshal([]byte(cors), &contourCORSPolicy); err != nil { + return nil, err + } + + if len(contourCORSPolicy.AllowOrigin) == 0 || len(contourCORSPolicy.AllowMethods) == 0 { + return nil, fmt.Errorf("the following fields are required but are missing or empty: %s.allowOrigin and %s.allowMethods", corsPolicy, corsPolicy) + } + + fields := [][]v1.CORSHeaderValue{ + contourCORSPolicy.AllowMethods, + contourCORSPolicy.AllowHeaders, + contourCORSPolicy.ExposeHeaders, + } + userFriendlyError := []string{corsPolicy + ".allowMethods", corsPolicy + ".allowHeaders", corsPolicy + ".exposeHeaders"} + for i, field := range fields { + if len(field) > 0 { + validOption := regexp.MustCompile("^[a-zA-Z0-9!#$%&'*+.^_`|~-]+$") + for _, option := range field { + if !validOption.MatchString(string(option)) { + return nil, fmt.Errorf("option %q is invalid for %s", option, userFriendlyError[i]) + } + } + } + } + + if len(contourCORSPolicy.MaxAge) > 0 { + validOption := regexp.MustCompile(`^(((\d*(\.\d*)?h)|(\d*(\.\d*)?m)|(\d*(\.\d*)?s)|(\d*(\.\d*)?ms)|(\d*(\.\d*)?us)|(\d*(\.\d*)?µs)|(\d*(\.\d*)?ns))+|0)$`) + if !validOption.MatchString(contourCORSPolicy.MaxAge) { + return nil, fmt.Errorf("%s.maxAge is invalid. Must be 0 or \\d*(h|m|s|ms|us|ns)", corsPolicy) + } + } + } + v, ok := configMap.Data[visibilityConfigKey] if !ok { // These are the defaults. @@ -85,6 +125,7 @@ func NewContourFromConfigMap(configMap *corev1.ConfigMap) (*Contour, error) { }, TimeoutPolicyResponse: timeoutPolicyResponse, TimeoutPolicyIdle: timeoutPolicyIdle, + CORSPolicy: contourCORSPolicy, }, nil } entry := make(map[v1alpha1.IngressVisibility]visibilityValue) @@ -107,6 +148,7 @@ func NewContourFromConfigMap(configMap *corev1.ConfigMap) (*Contour, error) { VisibilityClasses: make(map[v1alpha1.IngressVisibility]string, 2), TimeoutPolicyResponse: timeoutPolicyResponse, TimeoutPolicyIdle: timeoutPolicyIdle, + CORSPolicy: contourCORSPolicy, } for key, value := range entry { // Check that the visibility makes sense. diff --git a/pkg/reconciler/contour/config/contour_test.go b/pkg/reconciler/contour/config/contour_test.go index adf91b941..94e4a33ab 100644 --- a/pkg/reconciler/contour/config/contour_test.go +++ b/pkg/reconciler/contour/config/contour_test.go @@ -19,6 +19,9 @@ package config import ( "testing" + "github.com/google/go-cmp/cmp" + + v1 "github.com/projectcontour/contour/apis/projectcontour/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -40,6 +43,231 @@ func TestContour(t *testing.T) { } } +func TestCORSPolicy(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: system.Namespace(), + Name: ContourConfigName, + }, + Data: map[string]string{ + corsPolicy: ` +allowCredentials: true +allowOrigin: + - "*" +allowMethods: + - GET + - POST + - OPTIONS +allowHeaders: + - authorization + - cache-control +exposeHeaders: + - Content-Length + - Content-Range +maxAge: "10m" +`, + }, + } + + cfg, err := NewContourFromConfigMap(cm) + if err != nil { + t.Error("NewContourFromConfigMap(corsPolicy) =", err) + t.FailNow() + } + + want := &v1.CORSPolicy{ + AllowCredentials: true, + AllowOrigin: []string{"*"}, + AllowMethods: []v1.CORSHeaderValue{"GET", "POST", "OPTIONS"}, + AllowHeaders: []v1.CORSHeaderValue{"authorization", "cache-control"}, + ExposeHeaders: []v1.CORSHeaderValue{"Content-Length", "Content-Range"}, + MaxAge: "10m", + } + got := cfg.CORSPolicy + if !cmp.Equal(got, want) { + t.Errorf("Got = %v, want: %v, diff:\n%s", got, want, cmp.Diff(got, want)) + } +} +func TestCORSPolicyConfigurationErrors(t *testing.T) { + tests := []struct { + name string + wantErr bool + config *corev1.ConfigMap + }{{ + name: "failure parsing yaml", + wantErr: true, + config: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: system.Namespace(), + Name: ContourConfigName, + }, + Data: map[string]string{ + corsPolicy: "moo", + }, + }, + }, { + name: "wrong type", + wantErr: true, + config: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: system.Namespace(), + Name: ContourConfigName, + }, + Data: map[string]string{ + corsPolicy: ` +allowCredentials: 3 +allowOrigin: true +allowMethods: "moo" +allowHeaders: + - authorization + - cache-control +exposeHeaders: + - Content-Length + - Content-Range +maxAge: "10m" +`, + }, + }, + }, { + name: "incomplete configuration", + wantErr: true, + config: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: system.Namespace(), + Name: ContourConfigName, + }, + Data: map[string]string{ + corsPolicy: ` +allowHeaders: + - authorization + - cache-control +exposeHeaders: + - Content-Length + - Content-Range +maxAge: "10m" +`, + }, + }, + }, { + name: "empty value", + wantErr: true, + config: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: system.Namespace(), + Name: ContourConfigName, + }, + Data: map[string]string{ + corsPolicy: ` +allowCredentials: false +allowOrigin: [] +allowMethods: + - GET + - POST + - OPTIONS +allowHeaders: + - authorization + - cache-control +exposeHeaders: + - Content-Length + - Content-Range +maxAge: "10m" +`, + }, + }, + }, { + name: "wrong option", + wantErr: true, + config: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: system.Namespace(), + Name: ContourConfigName, + }, + Data: map[string]string{ + corsPolicy: ` +allowCredentials: true +allowOrigin: + - "*" +allowMethods: + - ((GET)) + - POST + - OPTIONS +allowHeaders: + - authorization + - cache-control +exposeHeaders: + - Content-Length + - Content-Range +maxAge: "10m" +`, + }, + }, + }, { + name: "invalid duration", + wantErr: true, + config: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: system.Namespace(), + Name: ContourConfigName, + }, + Data: map[string]string{ + corsPolicy: ` +allowCredentials: true +allowOrigin: + - "*" +allowMethods: + - GET + - POST + - OPTIONS +allowHeaders: + - authorization + - cache-control +exposeHeaders: + - Content-Length + - Content-Range +maxAge: "10" +`, + }, + }, + }, { + name: "invalid duration", + wantErr: true, + config: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: system.Namespace(), + Name: ContourConfigName, + }, + Data: map[string]string{ + corsPolicy: ` +allowCredentials: true +allowOrigin: + - "*" +allowMethods: + - GET + - POST + - OPTIONS +allowHeaders: + - authorization + - cache-control +exposeHeaders: + - Content-Length + - Content-Range +maxAge: "-2ms" +`, + }, + }, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewContourFromConfigMap(tt.config) + t.Log(err) + if (err != nil) != tt.wantErr { + t.Fatalf("Test: %q; NewContourFromConfigMap() error = %v, WantErr %v", tt.name, err, tt.wantErr) + } + }) + } +} + func TestDefaultTLSSecret(t *testing.T) { cm := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ diff --git a/pkg/reconciler/contour/config/zz_generated.deepcopy.go b/pkg/reconciler/contour/config/zz_generated.deepcopy.go index 27126e046..5de852169 100644 --- a/pkg/reconciler/contour/config/zz_generated.deepcopy.go +++ b/pkg/reconciler/contour/config/zz_generated.deepcopy.go @@ -22,6 +22,7 @@ limitations under the License. package config import ( + v1 "github.com/projectcontour/contour/apis/projectcontour/v1" types "k8s.io/apimachinery/pkg/types" sets "k8s.io/apimachinery/pkg/util/sets" v1alpha1 "knative.dev/networking/pkg/apis/networking/v1alpha1" @@ -86,6 +87,11 @@ func (in *Contour) DeepCopyInto(out *Contour) { *out = new(types.NamespacedName) **out = **in } + if in.CORSPolicy != nil { + in, out := &in.CORSPolicy, &out.CORSPolicy + *out = new(v1.CORSPolicy) + (*in).DeepCopyInto(*out) + } return } diff --git a/pkg/reconciler/contour/resources/httpproxy.go b/pkg/reconciler/contour/resources/httpproxy.go index 7c47f5f50..6178d2b73 100644 --- a/pkg/reconciler/contour/resources/httpproxy.go +++ b/pkg/reconciler/contour/resources/httpproxy.go @@ -300,10 +300,15 @@ func MakeHTTPProxies(ctx context.Context, ing *v1alpha1.Ingress, serviceToProtoc } hostProxy.Name = kmeta.ChildName(ing.Name+"-"+class+"-", host) + hostProxy.Spec.VirtualHost = &v1.VirtualHost{ Fqdn: host, } + if cfg.Contour.CORSPolicy != nil { + hostProxy.Spec.VirtualHost.CORSPolicy = cfg.Contour.CORSPolicy + } + // Set ExtensionService if annotation is present if extensionService, ok := ing.Annotations[ExtensionServiceKey]; ok { hostProxy.Spec.VirtualHost.Authorization = &v1.AuthorizationServer{} diff --git a/pkg/reconciler/contour/resources/httpproxy_test.go b/pkg/reconciler/contour/resources/httpproxy_test.go index c18581d82..4ce7af816 100644 --- a/pkg/reconciler/contour/resources/httpproxy_test.go +++ b/pkg/reconciler/contour/resources/httpproxy_test.go @@ -2386,6 +2386,233 @@ func TestMakeProxiesInternalEncryption(t *testing.T) { } } +func TestMakeProxiesCORSPolicy(t *testing.T) { + protocol := "h2c" + serviceToProtocol := map[string]string{ + "goo": protocol, + } + + tests := []struct { + name string + ing *v1alpha1.Ingress + want []*v1.HTTPProxy + modifyConfig func(*config.Config) + }{{ + name: "set any corsPolicy value", + ing: &v1alpha1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "foo", + Name: "bar", + }, + Spec: v1alpha1.IngressSpec{ + HTTPOption: v1alpha1.HTTPOptionEnabled, + Rules: []v1alpha1.IngressRule{{ + Hosts: []string{"example.com"}, + Visibility: v1alpha1.IngressVisibilityExternalIP, + HTTP: &v1alpha1.HTTPIngressRuleValue{ + Paths: []v1alpha1.HTTPIngressPath{{ + AppendHeaders: map[string]string{ + "Foo": "bar", + }, + Splits: []v1alpha1.IngressBackendSplit{{ + IngressBackend: v1alpha1.IngressBackend{ + ServiceName: "goo", + ServicePort: intstr.FromInt(123), + }, + Percent: 12, + AppendHeaders: map[string]string{ + "Baz": "blah", + "Bleep": "bloop", + }, + }, { + IngressBackend: v1alpha1.IngressBackend{ + ServiceName: "doo", + ServicePort: intstr.FromInt(124), + }, + Percent: 88, + AppendHeaders: map[string]string{ + "Baz": "blurg", + }, + }}, + }}, + }, + }}, + }, + }, + want: []*v1.HTTPProxy{{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "foo", + Name: "bar-" + publicClass + "-example.com", + Labels: map[string]string{ + DomainHashKey: "0caaf24ab1a0c33440c06afe99df986365b0781f", + GenerationKey: "0", + ParentKey: "bar", + ClassKey: publicClass, + }, + Annotations: map[string]string{ + ClassKey: publicClass, + }, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: "networking.internal.knative.dev/v1alpha1", + Kind: "Ingress", + Name: "bar", + Controller: ptr.Bool(true), + BlockOwnerDeletion: ptr.Bool(true), + }}, + }, + Spec: v1.HTTPProxySpec{ + VirtualHost: &v1.VirtualHost{ + Fqdn: "example.com", + CORSPolicy: &v1.CORSPolicy{ + AllowCredentials: true, + AllowOrigin: []string{ + "*", + }, + AllowMethods: []v1.CORSHeaderValue{ + "GET", + "POST", + "OPTIONS", + }, + AllowHeaders: []v1.CORSHeaderValue{ + "authorization", + "cache-control", + }, + ExposeHeaders: []v1.CORSHeaderValue{ + "Content-Length", + "Content-Range", + }, + MaxAge: "10m", + }, + }, + Routes: []v1.Route{{ + EnableWebsockets: true, + PermitInsecure: true, + TimeoutPolicy: &v1.TimeoutPolicy{ + Response: "infinity", + Idle: "infinity", + }, + RetryPolicy: defaultRetryPolicy(), + Conditions: []v1.MatchCondition{{ + Header: &v1.HeaderMatchCondition{ + Name: "K-Network-Hash", + Exact: "override", + }, + }}, + RequestHeadersPolicy: &v1.HeadersPolicy{ + Set: []v1.HeaderValue{{ + Name: "Foo", + Value: "bar", + }, { + Name: "K-Network-Hash", + Value: "418ee51d5bf437558dd840aa1566207fdb00ef57619ed17c0941e4b91d35b63e", + }}, + }, + Services: []v1.Service{{ + Name: "goo", + Port: 123, + Protocol: &protocol, + Weight: 12, + RequestHeadersPolicy: &v1.HeadersPolicy{ + Set: []v1.HeaderValue{{ + Name: "Baz", + Value: "blah", + }, { + Name: "Bleep", + Value: "bloop", + }}, + }, + }, { + Name: "doo", + Port: 124, + Weight: 88, + RequestHeadersPolicy: &v1.HeadersPolicy{ + Set: []v1.HeaderValue{{ + Name: "Baz", + Value: "blurg", + }}, + }, + }}, + }, { + EnableWebsockets: true, + PermitInsecure: true, + TimeoutPolicy: &v1.TimeoutPolicy{ + Response: "infinity", + Idle: "infinity", + }, + RetryPolicy: defaultRetryPolicy(), + RequestHeadersPolicy: &v1.HeadersPolicy{ + Set: []v1.HeaderValue{{ + Name: "Foo", + Value: "bar", + }}, + }, + Services: []v1.Service{{ + Name: "goo", + Port: 123, + Protocol: &protocol, + Weight: 12, + RequestHeadersPolicy: &v1.HeadersPolicy{ + Set: []v1.HeaderValue{{ + Name: "Baz", + Value: "blah", + }, { + Name: "Bleep", + Value: "bloop", + }}, + }, + }, { + Name: "doo", + Port: 124, + Weight: 88, + RequestHeadersPolicy: &v1.HeadersPolicy{ + Set: []v1.HeaderValue{{ + Name: "Baz", + Value: "blurg", + }}, + }, + }}, + }}, + }, + }}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + config := &config.Config{ + Contour: &config.Contour{ + VisibilityClasses: map[v1alpha1.IngressVisibility]string{ + v1alpha1.IngressVisibilityClusterLocal: privateClass, + v1alpha1.IngressVisibilityExternalIP: publicClass, + }, + TimeoutPolicyResponse: "infinity", + TimeoutPolicyIdle: "infinity", + CORSPolicy: &v1.CORSPolicy{ + AllowCredentials: true, + AllowOrigin: []string{"*"}, + AllowMethods: []v1.CORSHeaderValue{"GET", "POST", "OPTIONS"}, + AllowHeaders: []v1.CORSHeaderValue{"authorization", "cache-control"}, + ExposeHeaders: []v1.CORSHeaderValue{"Content-Length", "Content-Range"}, + MaxAge: "10m", + }, + }, + } + + if test.modifyConfig != nil { + test.modifyConfig(config) + } + + tcs := &testConfigStore{config: config} + ctx := tcs.ToContext(context.Background()) + + got := MakeHTTPProxies(ctx, test.ing, serviceToProtocol) + if !cmp.Equal(test.want, got) { + t.Error("MakeHTTPProxies (-want, +got) =", cmp.Diff(test.want, got)) + } + }) + } +} + func TestServiceNames(t *testing.T) { tests := []struct { name string