PROTECTING AN AWS ALB BEHIND AN AWS CLOUDFRONT DISTRIBUTION

Reducing the number of entry points into VPCs reduce the surface of possible attacks. Which in the end makes our infrastructures a lot more secure. When an AWS Cloudfront distribution has an AWS Application LoadBalancer (ALB) as an origin, the ALB must be public (internet-facing) and therefore, is by default accessible on all the ports defined by our listeners (usually 80 and 443).

At first glance this does not seem problematic. Without a Cloudfront distribution these ports are already opened to the world (0.0.0.0/0). But in reality we are still exposed to (Distributed) Denial-Of-Service (DoS and DDoS) and Denial-of-Wallet (DoW) attacks.

  • Cloudfront helps mitigate DDoS attacks
  • A Web Application Firewall (WAF) can prevent DoS attacks with a Rate Based Rule, which will limit DoW as-well
  • And with Bugdet Alerts we can be notified of unusual expenses

We could have one WAF on the Cloudfront distribution and another one on the ALB, but this would double the costs and increase the latency, and Cloudfront could still be by-passed.

In the following chart we have 2 different ways of reaching an unsecured internet-facing ALB:

Chart

The ojbective of this article is to show how to get rid-off the red lines.

CLOUFORMATION TEMPLATES

All the source code presented in this article is available in a Github repository.

To make the deployment of the Cloudformation templates easier we are using Ansible. The article Cloudformation with Ansible explains how it works.

The repository is organized as follow:

  • vars – configuration used by Ansible to deploy the Cloudformation templates
  • initialization – create a bucket to store assets used during the provisioning and another one to store frontend resources
  • step_xxx – different ways to configure a Cloudfront distribution, an ALB and a WAF
  • lambdas – AWS Lambdas used in the infrastructure

To have a simple example, for this article the Cloudfront distribution forwards all requests to the ALB and the targets are Nginx servers serving static resources from an S3 Bucket.

Please note that by deploying these stacks, you will be charged for the following resources:

  • 1 NAT Gateway
  • 1 S3 VPC endpoint
  • 1 ALB
  • 1 WAF
  • 1 t3.nano EC2 instance

UNSECURED INFRASTRUCTURE

Starting from an initial infrastructure (step_0) where an ALB has a Security group opened to the world and two Listeners which forward all requests to the targets without any restrictions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
AlbSecurityGroup:
  Type: AWS::EC2::SecurityGroup
  Properties:
    GroupName: alb-sg
    GroupDescription: Allows access to the Reverse Proxy ALB
    VpcId: !Ref VpcId
    SecurityGroupIngress:
      - CidrIp: 0.0.0.0/0
        IpProtocol: tcp
        FromPort: 80
        ToPort: 80
        Description: Allow Connection from the World
      - CidrIp: 0.0.0.0/0
        IpProtocol: tcp
        FromPort: 443
        ToPort: 443
        Description: Allow Connection from the World

HttpAppListener:
  Type: AWS::ElasticLoadBalancingV2::Listener
  Properties:
    DefaultActions:
      - Type: forward
        TargetGroupArn: !Ref ReverseProxyTargetGroup
    LoadBalancerArn: !Ref ReverseProxyLoadBalancer
    Port: 80
    Protocol: HTTP

HttpsAppListener:
  Type: AWS::ElasticLoadBalancingV2::Listener
  Properties:
    DefaultActions:
      - Type: forward
        TargetGroupArn: !Ref ReverseProxyTargetGroup
    LoadBalancerArn: !Ref ReverseProxyLoadBalancer
    Port: 443
    Protocol: HTTPS
    Certificates:
      - CertificateArn: !Ref AlbCertificateArn

The network flow for a regular user would be:

  1. example.com
  2. Cloudfront
  3. app.example.com
  4. ALB
  5. EC2 instance

The ALB being an internet-facing ALB, we can access the application using:

  1. app.example.com
  2. the ALB DNS
  3. the ALB IPs

Having a reverse proxy being able to serve multiple applications routed by Nginx using the Host header is even worse. Not having example.com in the Host header means that Nginx will fallback to its default page.

Furthermore, nothing forbid anyone to have its own Cloudfront distribution (or any reverse proxy) and use app.example.com as an origin.

Futhermore, in this example we are using CNAMEs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
CloudfrontRecords:
  Type: AWS::Route53::RecordSetGroup
  Properties:
    HostedZoneId: !Ref PublicHostedZoneId
    RecordSets:
      - Name: !Ref PublicDns
        Type: CNAME
        TTL: 60
        ResourceRecords:
          - !GetAtt CloudFrontDistribution.DomainName

ReverseProxyRecord:
  Type: AWS::Route53::RecordSetGroup
  Properties:
    HostedZoneId: !Ref PublicHostedZoneId
    RecordSets:
      - Name: !Ref ReverseProxyDns
        Type: CNAME
        TTL: 60
        ResourceRecords:
          - !GetAtt ReverseProxyLoadBalancer.DNSName

A simple dig command can return the DNS of the Cloudfront distribution and the ALB. Both DNS allowing us to access the application. It is one reason why Aliases should be preferred when possible, using the principle of “Security by obfuscation”, which is very weak but is better than nothing.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    CloudfrontRecords:
      Type: AWS::Route53::RecordSetGroup
      Properties:
        HostedZoneId: !Ref PublicHostedZoneId
        RecordSets:
          - Name: !Ref PublicDns
            Type: A
            AliasTarget:
              HostedZoneId: Z2FDTNDATAQYW2
              DNSName: !GetAtt CloudFrontDistribution.DomainName

    ReverseProxyRecord:
      Type: AWS::Route53::RecordSetGroup
      Properties:
        HostedZoneId: !Ref PublicHostedZoneId
        RecordSets:
          - Name: !Ref ReverseProxyDns
            Type: A
            AliasTarget:
              DNSName: !GetAtt ReverseProxyLoadBalancer.DNSName
              HostedZoneId: !GetAtt ReverseProxyLoadBalancer.CanonicalHostedZoneID

RESTRICTING ACCESS TO OUR ALB

The first (and most important) thing to secure is our ALB. Only Cloudfront should be able to access it.

To achieve this, the ALB Security Group should only allow access from Cloudfront IPs (step_1).

Cloudfront being distributed, it has dozens of dynamic IPs. Fortunately, AWS publishes the IPs used by Cloudfront and has even an SNS Topic triggered every time the IP ranges change. Furthermore, AWS provides the lambda that can update our Security Groups automatically.

The lambda provided by AWS using Python 2 and requiring to have specific Security Group names (cloudfront_g and cloudfront_r), in the github repository dedicated to this article you will find the lambda ported to Python 3 and using a tag SecurityGroupType, instead of Name.

As mentioned before, we need more than one Security Group, due to the limit of 60 rules per Security Group. We can have one Security Group by Cloudfront IP type (Global or Regional) and port (80 or 443):

  • Cloudfront Global HTTP
  • Cloudfront Global HTTPS
  • Cloudfront Regional HTTP
  • Cloudfront Regional HTTPS

Until now the ALB allowed access to the port 80, which is useless as Cloudfront has been configured to access the origin only in HTTPS:

1
2
3
4
5
6
Origins:
  - Id: ReverseProxy
    DomainName: !Ref ReverseProxyDns
    CustomOriginConfig:
      OriginProtocolPolicy: https-only
      OriginReadTimeout: 60

So we will :

  • remove the HTTP Listener (HttpAppListener)
  • all the Ingress Rules of the current ALB Security Group
  • add 2 new Security Groups

The 3 Security Groups should be defined as below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
AlbSecurityGroup:
  Type: AWS::EC2::SecurityGroup
  Properties:
    GroupName: alb-sg
    GroupDescription: Allows access to the Reverse Proxy ALB
    VpcId: !Ref VpcId

AlbHttpsCloudfrontGSG:
  Type: AWS::EC2::SecurityGroup
  Properties:
    GroupName: alb-cloudfront-global-https-sg
    GroupDescription: Cloudfront Global HTTPS
    VpcId: !Ref VpcId
    Tags:
      - Key: SecurityGroupType
        Value: cloudfront_g
      - Key: AutoUpdate
        Value: true
      - Key: Protocol
        Value: https

AlbHttpsCloudfrontRSG:
  Type: AWS::EC2::SecurityGroup
  Properties:
    GroupName: alb-cloudfront-regional-https-sg
    GroupDescription: Cloudfront Regional HTTPS
    VpcId: !Ref VpcId
    Tags:
      - Key: SecurityGroupType
        Value: cloudfront_r
      - Key: AutoUpdate
        Value: true
      - Key: Protocol
        Value: https

The three tags on the Security Groups will help the Lambda identify which Security Groups to update.

Moreover, having a Security Group without any rule is useful to allow access to a resource from the holder of the Security Group.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
ReverseProxyLoadBalancer:
  Type: AWS::ElasticLoadBalancingV2::LoadBalancer
  Properties:
    Scheme: internet-facing
    Type: application
    Subnets: !Ref AlbSubnets
    LoadBalancerAttributes:
      - Key: idle_timeout.timeout_seconds
        Value: 100
    SecurityGroups:
      - !Ref AlbSecurityGroup
      - !Ref AlbHttpsCloudfrontGSG
      - !Ref AlbHttpsCloudfrontRSG

ReverseProxySecurityGroup:
  Type: AWS::EC2::SecurityGroup
  Properties:
    GroupName: application-sg
    GroupDescription: Allows access to the Reverse Proxy
    VpcId: !Ref VpcId
    SecurityGroupIngress:
      - SourceSecurityGroupId: !Ref AlbSecurityGroup
        IpProtocol: tcp
        FromPort: 80
        ToPort: 80
        Description: Allow Connection from the Reverse Proxy ALB

ReverseProxySecurityGroup allows access to any resource holding the Security Group AlbSecurityGroup on port 80.

Now only Cloudfront can access the ALB. Unfortunately, by any Cloudfront Distribution. And we can still use the DNS of the Cloudfront Distribution (not only example.com).

To configure the lambda and SNS subscription, please refer to the Cloudformation template and the README in the repository.

SIGNING A CLOUDFRONT DISTRIBUTION REQUEST TO THE ORIGIN

To make a Cloudfront Distribution the only source of truth for an ALB is quite simple. The Cloudfront Distribution must send a custom header to the origin (the ALB) and the ALB should forward the requests, only if the custom header is present in the request with the appropriate value, much like an API Token (step_2).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
CloudFrontDistribution:
  Type: AWS::CloudFront::Distribution
  Properties:
    DistributionConfig:
      Origins:
        - Id: ReverseProxy
          DomainName: !Ref ReverseProxyDns
          CustomOriginConfig:
            OriginProtocolPolicy: https-only
            OriginReadTimeout: 60
          OriginCustomHeaders:
            - HeaderName: x-com-token
              HeaderValue: !Ref SecurityToken
      [...]

HttpsSecuredListenerRule:
  Type: AWS::ElasticLoadBalancingV2::ListenerRule
  Properties:
    Actions:
      - Type: forward
        TargetGroupArn: !Ref ReverseProxyTargetGroup
    Conditions:
      - Field: http-header
        HttpHeaderConfig:
          HttpHeaderName: x-com-token
          Values:
            - !Ref SecurityToken
    ListenerArn: !Ref HttpsAppListener
    Priority: 1

Where the parameter SecurityToken is a shared value between Cloudfront and the ALB.

Finally, we need to change the default action of the listener:

1
2
3
4
5
6
7
8
HttpsAppListener:
  Type: AWS::ElasticLoadBalancingV2::Listener
  Properties:
    DefaultActions:
      - Type: fixed-response
        FixedResponseConfig:
          StatusCode: 403
    [...]

If a request doesn’t match the HttpsSecuredListenerRule defined previously, the ALB will return a 403 error response.

HOSTS WHITELISTING

The last improvement we need is to allow access to the Cloudfront Distribution only with our domain name. And the best way to do this is to use a WAF and attach it to the Cloudfront Distribution (step_3). We could check the Host header at the ALB level, but this check must be done as close to the user as possible, in other words, in edge locations.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
WafValidHostsCondition:
  Type: AWS::WAF::ByteMatchSet
  Properties:
    Name: waf-validhosts
    ByteMatchTuples:
      - FieldToMatch:
          Type: HEADER
          Data: host
        TargetString: !Ref PublicDns
        TextTransformation: NONE
        PositionalConstraint: EXACTLY

WafValidHostsRule:
  Type: AWS::WAF::Rule
  Properties:
    Name: waf-validhosts-rule
    MetricName: WafValidHostsRule
    Predicates:
      - DataId: !Ref WafValidHostsCondition
        Negated: true
        Type: ByteMatch

WebAcl:
  Type: AWS::WAF::WebACL
  Properties:
    Name: globalwebacl
    DefaultAction:
      Type: ALLOW
    MetricName: GlobalWebACL
    Rules:
      - Action:
          Type: BLOCK
        Priority: 1
        RuleId: !Ref WafValidHostsRule

CONCLUSION

With little effort with have secured a Cloudfront Distribution and an ALB behind it.

To summarize the steps have been as follow:

  1. Using Aliases instead of CNAMEs when possible
  2. Restricting access to the internet-facing ALB to Cloudfront only and only on HTTPS port 443
  3. Allowing access to the internet-facing ALB only to our Cloudfront distribution by exchanging a secret
  4. Using a WAF to allow access to our Cloudfront distribution from whitelisted Hosts

Software development Business intelligence Infrastructure Digital trust Mobile developent