Skip to content

WAFv2 support #1211

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 18, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/examples/iam-policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,16 @@
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"wafv2:GetWebACL",
"wafv2:GetWebACLForResource",
"wafv2:AssociateWebACL",
"wafv2:DisassociateWebACL"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
Expand Down
14 changes: 14 additions & 0 deletions docs/guide/ingress/annotation.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ You can add kubernetes annotations to ingress and service objects to customize t
|[alb.ingress.kubernetes.io/target-type](#target-type)|instance \| ip|instance|ingress,service|
|[alb.ingress.kubernetes.io/unhealthy-threshold-count](#unhealthy-threshold-count)|integer|'2'|ingress,service|
|[alb.ingress.kubernetes.io/waf-acl-id](#waf-acl-id)|string|N/A|ingress|
|[alb.ingress.kubernetes.io/wafv2-acl-arn](#wafv2-acl-arn)|string|N/A|ingress|

## Traffic Listening
Traffic Listening can be controlled with following annotations:
Expand Down Expand Up @@ -497,6 +498,19 @@ Health check on target groups can be controlled with following annotations:
```alb.ingress.kubernetes.io/waf-acl-id: 499e8b99-6671-4614-a86d-adb1810b7fbe
```

## WAFv2
- <a name="wafv2-acl-arn">`alb.ingress.kubernetes.io/wafv2-acl-arn`</a> specifies ARN for the Amazon WAFv2 web ACL.

!!!warning ""
Only Regional WAFv2 is supported.

!!!example
```alb.ingress.kubernetes.io/wafv2-acl-arn: arn:aws:wafv2:us-west-2:xxxxx:regional/webacl/xxxxxxx/3ab78708-85b0-49d3-b4e1-7a9615a6613b
```

!!!tip ""
To get the WAFv2 Web ACL ARN from the Console, click the gear icon in the upper right and enable the ARN column.

## Shield Advanced
- <a name="shield-advanced-protection">`alb.ingress.kubernetes.io/shield-advanced-protection`</a> turns on / off the AWS Shield Advanced protection for the load balancer.

Expand Down
9 changes: 9 additions & 0 deletions internal/alb/lb/loadbalancer.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func NewController(
tagsController tags.Controller) Controller {
attrsController := NewAttributesController(cloud)
wafController := NewWAFController(cloud)
wafV2Controller := NewWAFV2Controller(cloud)
shieldController := NewShieldController(cloud)

return &defaultController{
Expand All @@ -58,6 +59,7 @@ func NewController(
tagsController: tagsController,
attrsController: attrsController,
wafController: wafController,
wafV2Controller: wafV2Controller,
shieldController: shieldController,
}
}
Expand All @@ -83,6 +85,7 @@ type defaultController struct {
tagsController tags.Controller
attrsController AttributesController
wafController WAFController
wafV2Controller WAFV2Controller
shieldController ShieldController
}

Expand Down Expand Up @@ -122,6 +125,12 @@ func (controller *defaultController) Reconcile(ctx context.Context, ingress *ext
}
}

if controller.store.GetConfig().FeatureGate.Enabled(config.WAFV2) {
if err := controller.wafV2Controller.Reconcile(ctx, lbArn, ingress); err != nil {
return nil, err
}
}

if controller.store.GetConfig().FeatureGate.Enabled(config.ShieldAdvanced) {
if err := controller.shieldController.Reconcile(ctx, lbArn, ingress); err != nil {
return nil, err
Expand Down
92 changes: 92 additions & 0 deletions internal/alb/lb/wafv2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package lb

import (
"context"
"time"

"github.com/kubernetes-sigs/aws-alb-ingress-controller/internal/albctx"
"github.com/kubernetes-sigs/aws-alb-ingress-controller/internal/aws"
"github.com/kubernetes-sigs/aws-alb-ingress-controller/internal/ingress/annotations"
"github.com/pkg/errors"
extensions "k8s.io/api/extensions/v1beta1"
"k8s.io/apimachinery/pkg/util/cache"
)

const (
webACLARNForLBCacheMaxSize = 1024
webACLARNForLBCacheTTL = 10 * time.Minute
)

// WAFCV2ontroller provides functionality to manage ALB's WAF V2 associations.
type WAFV2Controller interface {
Reconcile(ctx context.Context, lbArn string, ingress *extensions.Ingress) error
}

func NewWAFV2Controller(cloud aws.CloudAPI) WAFV2Controller {
return &defaultWAFV2Controller{
cloud: cloud,
webACLARNForLBCache: cache.NewLRUExpireCache(webACLARNForLBCacheMaxSize),
}
}

type defaultWAFV2Controller struct {
cloud aws.CloudAPI

// cache that stores webACLARNForLBCache for LoadBalancerARN.
// The cache value is string, while "" represents no webACL.
webACLARNForLBCache *cache.LRUExpireCache
}

func (c *defaultWAFV2Controller) Reconcile(ctx context.Context, lbArn string, ing *extensions.Ingress) error {
var desiredWebACLARN string

_ = annotations.LoadStringAnnotation("wafv2-acl-arn", &desiredWebACLARN, ing.Annotations)

currentWebACLId, err := c.getCurrentWebACLARN(ctx, lbArn)
if err != nil {
return err
}

switch {
case desiredWebACLARN == "" && currentWebACLId != "":
albctx.GetLogger(ctx).Infof("disassociate WAFv2 webACL on %v", lbArn)
if _, err := c.cloud.DisassociateWAFV2(ctx, aws.String(lbArn)); err != nil {
return errors.Wrapf(err, "failed to disassociate WAFv2 webACL on LoadBalancer %v", lbArn)
}
c.webACLARNForLBCache.Add(lbArn, desiredWebACLARN, webACLARNForLBCacheTTL)
case desiredWebACLARN != "" && currentWebACLId != "" && desiredWebACLARN != currentWebACLId:
albctx.GetLogger(ctx).Infof("change WAFv2 webACL on %v from %v to %v", lbArn, currentWebACLId, desiredWebACLARN)
if _, err := c.cloud.AssociateWAFV2(ctx, aws.String(lbArn), aws.String(desiredWebACLARN)); err != nil {
return errors.Wrapf(err, "failed to associate WAFv2 webACL on LoadBalancer %v", lbArn)
}
c.webACLARNForLBCache.Add(lbArn, desiredWebACLARN, webACLARNForLBCacheTTL)
case desiredWebACLARN != "" && currentWebACLId == "":
albctx.GetLogger(ctx).Infof("associate WAFv2 webACL %v on %v", desiredWebACLARN, lbArn)
if _, err := c.cloud.AssociateWAFV2(ctx, aws.String(lbArn), aws.String(desiredWebACLARN)); err != nil {
return errors.Wrapf(err, "failed to associate WAFv2 webACL on LoadBalancer %v", lbArn)
}
c.webACLARNForLBCache.Add(lbArn, desiredWebACLARN, webACLARNForLBCacheTTL)
}

return nil
}

func (c *defaultWAFV2Controller) getCurrentWebACLARN(ctx context.Context, lbArn string) (string, error) {
cachedWebACLARN, exists := c.webACLARNForLBCache.Get(lbArn)
if exists {
return cachedWebACLARN.(string), nil
}

webACL, err := c.cloud.GetWAFV2WebACLSummary(ctx, aws.String(lbArn))
if err != nil {
return "", errors.Wrapf(err, "failed get WAFv2 webACL for load balancer %v", lbArn)
}

var webACLARN string
if webACL != nil {
webACLARN = aws.StringValue(webACL.ARN)
}

c.webACLARNForLBCache.Add(lbArn, webACLARN, webACLARNForLBCacheTTL)
return webACLARN, nil
}
205 changes: 205 additions & 0 deletions internal/alb/lb/wafv2_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package lb

import (
"context"
"testing"

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

"github.com/aws/aws-sdk-go/service/wafv2"
"github.com/kubernetes-sigs/aws-alb-ingress-controller/internal/aws"
"github.com/kubernetes-sigs/aws-alb-ingress-controller/mocks"
"github.com/stretchr/testify/assert"
apiv1 "k8s.io/api/core/v1"
extensions "k8s.io/api/extensions/v1beta1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func buildWAFV2TestIngress(wafIngressAnnotations map[string]string) *extensions.Ingress {
defaultBackend := extensions.IngressBackend{
ServiceName: "default-backend",
ServicePort: intstr.FromInt(80),
}

return &extensions.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: "foo",
Namespace: apiv1.NamespaceDefault,
Annotations: wafIngressAnnotations,
},
Spec: extensions.IngressSpec{
Backend: &extensions.IngressBackend{
ServiceName: "default-backend",
ServicePort: intstr.FromInt(80),
},
Rules: []extensions.IngressRule{
{
Host: "foo.bar.com",
IngressRuleValue: extensions.IngressRuleValue{
HTTP: &extensions.HTTPIngressRuleValue{
Paths: []extensions.HTTPIngressPath{
{
Path: "/foo",
Backend: defaultBackend,
},
},
},
},
},
},
},
}
}

func Test_defaultWAFV2Controller_Reconcile(t *testing.T) {
for _, tc := range []struct {
Name string
GetWAFV2WebACLSummaryResponse *wafv2.WebACL
GetWAFV2WebACLSummaryError error
AssociateWAFV2Response *wafv2.AssociateWebACLOutput
AssociateWAFV2Error error
DisassociateWAFV2Response *wafv2.DisassociateWebACLOutput
DisassociateWAFV2Error error
Expected error
ExpectedError error
LoadBalancerARN string
IngressAnnotations *extensions.Ingress
DesiredWebACLARN string
GetWAFV2WebACLSummaryTimesCalled int
AssociateWAFV2TimesCalled int
DisassociateWAFV2TimesCalled int
}{
{
Name: "No annotation, confirm nothing is attached",
GetWAFV2WebACLSummaryResponse: &wafv2.WebACL{
ARN: nil,
},
GetWAFV2WebACLSummaryError: nil,
AssociateWAFV2Response: &wafv2.AssociateWebACLOutput{},
AssociateWAFV2Error: nil,
Expected: nil,
LoadBalancerARN: "arn:lb",
IngressAnnotations: buildWAFV2TestIngress(
map[string]string{},
),
DesiredWebACLARN: "",
GetWAFV2WebACLSummaryTimesCalled: 1,
AssociateWAFV2TimesCalled: 0,
DisassociateWAFV2TimesCalled: 0,
},
{
Name: "Empty WAFv2 annotation",
GetWAFV2WebACLSummaryResponse: &wafv2.WebACL{
ARN: nil,
},
GetWAFV2WebACLSummaryError: nil,
AssociateWAFV2Response: &wafv2.AssociateWebACLOutput{},
AssociateWAFV2Error: nil,
Expected: nil,
LoadBalancerARN: "arn:lb",
IngressAnnotations: buildWAFV2TestIngress(
map[string]string{
"alb.ingress.kubernetes.io/wafv2-acl-arn": "",
},
),
DesiredWebACLARN: "",
GetWAFV2WebACLSummaryTimesCalled: 1,
AssociateWAFV2TimesCalled: 0,
DisassociateWAFV2TimesCalled: 0,
},
{
Name: "No annotation, detach WAFv2",
GetWAFV2WebACLSummaryResponse: &wafv2.WebACL{
ARN: aws.String("arn:aws:wafv2:us-east-1:000000000000:regional/webacl/name/0aa00000-00a0-00a0-a0a0-0a0000a0000a"),
},
GetWAFV2WebACLSummaryError: nil,
AssociateWAFV2Response: &wafv2.AssociateWebACLOutput{},
AssociateWAFV2Error: nil,
Expected: nil,
LoadBalancerARN: "arn:lb",
IngressAnnotations: buildWAFV2TestIngress(
map[string]string{},
),
DesiredWebACLARN: "",
GetWAFV2WebACLSummaryTimesCalled: 1,
AssociateWAFV2TimesCalled: 0,
DisassociateWAFV2TimesCalled: 1,
},
{
Name: "Empty annotation, dissassociate WAFv2",
GetWAFV2WebACLSummaryResponse: &wafv2.WebACL{
ARN: aws.String("arn:aws:wafv2:us-east-1:000000000000:regional/webacl/name/0aa00000-00a0-00a0-a0a0-0a0000a0000a"),
},
GetWAFV2WebACLSummaryError: nil,
AssociateWAFV2Response: &wafv2.AssociateWebACLOutput{},
AssociateWAFV2Error: nil,
Expected: nil,
LoadBalancerARN: "arn:lb",
IngressAnnotations: buildWAFV2TestIngress(
map[string]string{
"alb.ingress.kubernetes.io/wafv2-acl-arn": "",
},
),
DesiredWebACLARN: "",
GetWAFV2WebACLSummaryTimesCalled: 1,
AssociateWAFV2TimesCalled: 0,
DisassociateWAFV2TimesCalled: 1,
},
{
Name: "Annotation, associate WAFv2",
GetWAFV2WebACLSummaryResponse: &wafv2.WebACL{
ARN: nil,
},
GetWAFV2WebACLSummaryError: nil,
AssociateWAFV2Response: &wafv2.AssociateWebACLOutput{},
AssociateWAFV2Error: nil,
Expected: nil,
LoadBalancerARN: "arn:lb",
IngressAnnotations: buildWAFV2TestIngress(
map[string]string{
"alb.ingress.kubernetes.io/wafv2-acl-arn": "arn:aws:wafv2:us-east-1:000000000000:regional/webacl/name/0aa00000-00a0-00a0-a0a0-0a0000a0000a",
},
),
DesiredWebACLARN: "arn:aws:wafv2:us-east-1:000000000000:regional/webacl/name/0aa00000-00a0-00a0-a0a0-0a0000a0000a",
GetWAFV2WebACLSummaryTimesCalled: 1,
AssociateWAFV2TimesCalled: 1,
DisassociateWAFV2TimesCalled: 0,
},
{
Name: "Annotation, change WAFv2",
GetWAFV2WebACLSummaryResponse: &wafv2.WebACL{
ARN: aws.String("arn:aws:wafv2:us-east-1:000000000000:regional/webacl/name/0bb00000-00b0-00b0-b0b0-0b0000a0000b"),
},
GetWAFV2WebACLSummaryError: nil,
Expected: nil,
AssociateWAFV2Response: &wafv2.AssociateWebACLOutput{},
AssociateWAFV2Error: nil,
LoadBalancerARN: "arn:lb",
IngressAnnotations: buildWAFV2TestIngress(
map[string]string{
"alb.ingress.kubernetes.io/wafv2-acl-arn": "arn:aws:wafv2:us-east-1:000000000000:regional/webacl/name/0aa00000-00a0-00a0-a0a0-0a0000a0000a",
},
),
DesiredWebACLARN: "arn:aws:wafv2:us-east-1:000000000000:regional/webacl/name/0aa00000-00a0-00a0-a0a0-0a0000a0000a",
GetWAFV2WebACLSummaryTimesCalled: 1,
AssociateWAFV2TimesCalled: 1,
DisassociateWAFV2TimesCalled: 0,
},
} {
t.Run(tc.Name, func(t *testing.T) {
ctx := context.Background()
cloud := &mocks.CloudAPI{}

cloud.On("GetWAFV2WebACLSummary", ctx, aws.String(tc.LoadBalancerARN)).Return(tc.GetWAFV2WebACLSummaryResponse, tc.GetWAFV2WebACLSummaryError)
cloud.On("AssociateWAFV2", ctx, aws.String(tc.LoadBalancerARN), aws.String(tc.DesiredWebACLARN)).Return(tc.AssociateWAFV2Response, tc.AssociateWAFV2Error)
cloud.On("DisassociateWAFV2", ctx, aws.String(tc.LoadBalancerARN)).Return(tc.DisassociateWAFV2Response, tc.DisassociateWAFV2Error)

controller := NewWAFV2Controller(cloud)
err := controller.Reconcile(ctx, tc.LoadBalancerARN, tc.IngressAnnotations)
assert.Equal(t, tc.Expected, err)
cloud.AssertNumberOfCalls(t, "GetWAFV2WebACLSummary", tc.GetWAFV2WebACLSummaryTimesCalled)
cloud.AssertNumberOfCalls(t, "AssociateWAFV2", tc.AssociateWAFV2TimesCalled)
cloud.AssertNumberOfCalls(t, "DisassociateWAFV2", tc.DisassociateWAFV2TimesCalled)
})
}
}
Loading