Skip to content

Commit

Permalink
Bug 1986228: NE-310 e2e test for HSTS
Browse files Browse the repository at this point in the history
  • Loading branch information
candita committed Aug 12, 2021
1 parent e2cdf40 commit 7438696
Show file tree
Hide file tree
Showing 6 changed files with 194 additions and 5 deletions.
1 change: 1 addition & 0 deletions go.mod
Expand Up @@ -35,6 +35,7 @@ require (
k8s.io/apimachinery v0.21.1
k8s.io/apiserver v0.21.1
k8s.io/client-go v0.21.1
k8s.io/utils v0.0.0-20210527160623-6fdb442a123b
sigs.k8s.io/controller-runtime v0.9.0
sigs.k8s.io/controller-tools v0.4.1
)
3 changes: 3 additions & 0 deletions pkg/manifests/manifests.go
Expand Up @@ -69,6 +69,9 @@ const (
// instance.
DefaultIngressControllerName = "default"

// ClusterIngressConfigName is the name of the cluster Ingress Config
ClusterIngressConfigName = "cluster"

NamespaceManifest = "manifests/00-namespace.yaml"
CustomResourceDefinitionManifest = "manifests/00-custom-resource-definition.yaml"
)
Expand Down
170 changes: 170 additions & 0 deletions test/e2e/hsts_policy_test.go
@@ -0,0 +1,170 @@
// +build e2e

package e2e

import (
"context"
"crypto/tls"
"fmt"
"net/http"
"testing"
"time"

configv1 "github.com/openshift/api/config/v1"
routev1 "github.com/openshift/api/route/v1"

corev1 "k8s.io/api/core/v1"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/wait"

"k8s.io/utils/pointer"
)

func TestHstsPolicyWorks(t *testing.T) {
// Setup a Required HSTS Policy for the ingress config
maxAgePolicy := configv1.MaxAgePolicy{LargestMaxAge: pointer.Int32Ptr(99999), SmallestMaxAge: pointer.Int32Ptr(0)}
domainPatterns := []string{}
hstsPolicy := configv1.RequiredHSTSPolicy{
DomainPatterns: domainPatterns, // this policy will only validate routes with hosts in the DomainPatterns
PreloadPolicy: configv1.RequirePreloadPolicy,
IncludeSubDomainsPolicy: configv1.RequireIncludeSubDomains,
MaxAge: maxAgePolicy,
}

ing := &configv1.Ingress{}
appsDomain := ""

// Update the ingress config with the new HSTS policy, and include its apps domain
if err := wait.PollImmediate(1*time.Second, 1*time.Minute, func() (bool, error) {
// Get the ingress config
if err := kclient.Get(context.TODO(), clusterConfigName, ing); err != nil {
t.Logf("Get ingress config failed: %v, retrying...", err)
return false, nil
}
// Update the hsts.DomainPatterns with the ingress spec domain
if !domainExists(ing.Spec.Domain, hstsPolicy.DomainPatterns) {
hstsPolicy.DomainPatterns = append(hstsPolicy.DomainPatterns, "*."+ing.Spec.Domain)
appsDomain = ing.Spec.Domain
}
// Update the ingress config with the new policy
ing.Spec.RequiredHSTSPolicies = append(ing.Spec.RequiredHSTSPolicies, hstsPolicy)
// Update the ingress config
if err := kclient.Update(context.TODO(), ing); err != nil {
t.Logf("failed to update ingress config: %v, retrying...", err)
return false, nil
}
return true, nil
}); err != nil {
t.Fatalf("failed to update ingress config: %v", err)
}
defer func() {
// Remove the HSTS policies from the ingress config
ing.Spec.RequiredHSTSPolicies = nil
if err := kclient.Update(context.TODO(), ing); err != nil {
t.Fatalf("failed to restore ingress config: %v", err)
}
}()

p := ing.Spec.RequiredHSTSPolicies[0]
t.Logf("created a RequiredHSTSPolicy with DomainPatterns: %v,\n preload policy: %s,\n includeSubDomains policy: %s,\n largest age: %d,\n smallest age: %d\n", p.DomainPatterns, p.PreloadPolicy, p.IncludeSubDomainsPolicy, *p.MaxAge.LargestMaxAge, *p.MaxAge.SmallestMaxAge)

// Use the same namespace for route, service, and pod
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: "hsts-policy-namespace",
},
}
if err := kclient.Create(context.TODO(), ns); err != nil {
t.Fatalf("failed to create namespace: %v", err)
}
defer func() {
// this will cleanup all components in this namespace
if err := kclient.Delete(context.TODO(), ns); err != nil {
t.Fatalf("failed to delete test namespace %s: %v", ns.Name, err)
}
}()

// Create pod
echoPod := buildEchoPod("hsts-policy-echo", ns.Name)
if err := kclient.Create(context.TODO(), echoPod); err != nil {
t.Fatalf("failed to create pod %s/%s: %v", ns, echoPod.Name, err)
}

// Create service
echoService := buildEchoService(echoPod.Name, ns.Name, echoPod.ObjectMeta.Labels)
if err := kclient.Create(context.TODO(), echoService); err != nil {
t.Fatalf("failed to create service %s/%s: %v", echoService.Namespace, echoService.Name, err)
}

// Create a route that should fail the HSTS policy validation
t.Logf("creating an invalid route at %s", time.Now().Format(time.StampMilli))
invalidRoute := buildRouteWithHSTS("invalid-route", echoPod.Namespace, echoService.Name, "invalid-route."+appsDomain, "max-age=99999999")
if err := kclient.Create(context.TODO(), invalidRoute); err == nil {
t.Fatalf("failed to reject an invalid route %s/%s, max-age 99999999", invalidRoute.Namespace, invalidRoute.Name)
} else {
t.Logf("rejected an invalid route at %s: %s/%s with annotation %s: %v", time.Now().Format(time.StampMilli), invalidRoute.Namespace, invalidRoute.Name, invalidRoute.Annotations, err)
}

// Create a route that should pass the HSTS policy validation
exampleHeader := "max-age=0;preload;includesubdomains"
t.Logf("creating a valid route at %s", time.Now().Format(time.StampMilli))
validRoute := buildRouteWithHSTS("valid-route", echoPod.Namespace, echoService.Name, "valid-route."+appsDomain, exampleHeader)
if err := kclient.Create(context.TODO(), validRoute); err != nil {
t.Fatalf("failed to create a valid route %s/%s: %v", validRoute.Namespace, validRoute.Name, err)
} else {
t.Logf("created a valid route at %s: %s/%s with annotation %s", time.Now().Format(time.StampMilli), validRoute.Namespace, validRoute.Name, validRoute.Annotations)
}

// Create the http client to check the header
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}

// Wait for route service to respond, and when it does, check for the header that should be there
if err := wait.PollImmediate(2*time.Second, 5*time.Minute, func() (bool, error) {
resultHeader, statusCode, err := getHttpHeader(client, validRoute)
if err != nil {
t.Logf("GET %s failed: %v, retrying...", validRoute.Spec.Host, err)
return false, nil
}
if statusCode != http.StatusOK {
t.Logf("GET %s failed: status %v, expected %v, retrying...", validRoute.Spec.Host, statusCode, http.StatusOK)
return false, nil // retry on 503 as pod/service may not be ready
}
header := resultHeader.Get("strict-transport-security")
if header != exampleHeader {
return false, fmt.Errorf("expected [%s], got [%s]", exampleHeader, header)
}
t.Logf("request to %s got correct HSTS header: [%s]", validRoute.Spec.Host, header)
return true, nil
}); err != nil {
t.Fatalf("failed to find header [%s]: %v", exampleHeader, err)
}
}

func domainExists(appsDomain string, patterns []string) bool {
for _, domain := range patterns {
if domain == appsDomain {
return true
}
}
return false
}

// Send HTTPS Request to a route and return the header
func getHttpHeader(client *http.Client, route *routev1.Route) (http.Header, int, error) {
// Send the HTTPS request
response, err := client.Get("https://" + route.Spec.Host)
if err != nil {
return nil, 0, fmt.Errorf("GET %s failed: %v", route.Spec.Host, err)
}

// Close response body
defer response.Body.Close()

return response.Header, response.StatusCode, nil
}
1 change: 1 addition & 0 deletions test/e2e/operator_test.go
Expand Up @@ -89,6 +89,7 @@ var dnsConfig configv1.DNS
var infraConfig configv1.Infrastructure
var operatorNamespace = operatorcontroller.DefaultOperatorNamespace
var defaultName = types.NamespacedName{Namespace: operatorNamespace, Name: manifests.DefaultIngressControllerName}
var clusterConfigName = types.NamespacedName{Namespace: operatorNamespace, Name: manifests.ClusterIngressConfigName}

func TestMain(m *testing.M) {
kubeConfig, err := config.GetConfig()
Expand Down
23 changes: 18 additions & 5 deletions test/e2e/util.go
Expand Up @@ -8,19 +8,17 @@ import (
"testing"
"time"

operatorv1 "github.com/openshift/api/operator/v1"
routev1 "github.com/openshift/api/route/v1"

appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

operatorv1 "github.com/openshift/api/operator/v1"

appsv1 "k8s.io/api/apps/v1"

"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/apimachinery/pkg/util/wait"

"sigs.k8s.io/controller-runtime/pkg/client"
)

Expand All @@ -37,6 +35,7 @@ func buildEchoPod(name, namespace string) *corev1.Pod {
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
// Note that HTTP/1.0 will strip the HSTS response header
Args: []string{
"TCP4-LISTEN:8080,reuseaddr,fork",
`EXEC:'/bin/bash -c \"printf \\\"HTTP/1.0 200 OK\r\n\r\n\\\"; sed -e \\\"/^\r/q\\\"\"'`,
Expand Down Expand Up @@ -145,6 +144,20 @@ func buildRoute(name, namespace, serviceName string) *routev1.Route {
}
}

// buildRoute returns a route definition with the specified HSTS annotation.
// Overwrites Spec.Host and TLS
func buildRouteWithHSTS(podName, namespace, serviceName, domain, annotation string) *routev1.Route {
route := buildRoute(podName, namespace, serviceName)
route.Spec.Host = domain
route.Spec.TLS = &routev1.TLSConfig{Termination: routev1.TLSTerminationEdge}
if route.Annotations == nil {
route.Annotations = map[string]string{}
}
route.Annotations["haproxy.router.openshift.io/hsts_header"] = annotation

return route
}

func getIngressController(t *testing.T, client client.Client, name types.NamespacedName, timeout time.Duration) (*operatorv1.IngressController, error) {
t.Helper()
ic := operatorv1.IngressController{}
Expand Down
1 change: 1 addition & 0 deletions vendor/modules.txt
Expand Up @@ -865,6 +865,7 @@ k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/typed/apiregistr
# k8s.io/kube-openapi v0.0.0-20210305001622-591a79e4bda7
k8s.io/kube-openapi/pkg/util/proto
# k8s.io/utils v0.0.0-20210527160623-6fdb442a123b
## explicit
k8s.io/utils/buffer
k8s.io/utils/integer
k8s.io/utils/pointer
Expand Down

0 comments on commit 7438696

Please sign in to comment.