Using AWS EKS with CloudFront and WAF

Introduction

In previous articles, I guided you through using AWS EKS to create Kubernetes resources in the traditional way using yaml files or using KubernetesManifest directly via AWS CDK. The result is that we access the application directly through the LoadBalancer Address, but this is only an HTTP connection. To enhance security, in this article, we will explore how to use it alongside CloudFront for HTTPS connections and WAF.

AWS WAF (Web Application Firewall) is a firewall service that protects web applications (delivered via CloudFront, ALB, or API Gateway) from common security vulnerabilities. Instead of just basic IP blocking, WAF deeply analyzes HTTP/HTTPS content to make decisions to allow or block requests.

Advantages

Protection against automated attacks: Effectively prevents common types of attacks such as SQL Injection, Cross-Site Scripting (XSS), and vulnerabilities in the OWASP Top 10.

Bot and DDoS Blocking: Uses AWS Managed Rules to block malicious bots from scraping data and application-layer (Layer 7) DDoS attacks.

Flexible Customization (Rate Limiting): Limits the number of requests from a single IP (e.g., maximum 100 req/5 minutes) to prevent Brute-force on login pages.

Rapid Deployment: No software installation required on the Server; it can be activated immediately on existing CloudFront or Load Balancers.


Prerequisites

This article is developed from my previous posts; you should review them to grasp information about S3, NestJS, building docker images, and pushing to ECR.

In the NestJS project, we will continue by creating the origin-auth.middleware.ts file with the following content:

import {Injectable, NestMiddleware, ForbiddenException} from '@nestjs/common'
import {ConfigService} from '@nestjs/config'

@Injectable()
export class OriginAuthMiddleware implements NestMiddleware {
  verifySecret: string

  constructor(private readonly configService: ConfigService) {
    this.verifySecret = this.configService.get<string>('VERIFY_SECRET') || ''
  }

  use(req: any, res: any, next: () => void) {
    const secret = req.headers['x-origin-verify']
    if (secret !== this.verifySecret) {
      throw new ForbiddenException(
        'Access denied: Direct access is not allowed'
      )
    }
    next()
  }
}

As you can see, I use middleware here to check if the request header field x-origin-verify matches the VERIFY_SECRET value. The VERIFY_SECRET will be sent from CloudFront, which I will create immediately below.


Next, add it to app.module.ts:

import {
  MiddlewareConsumer,
  Module,
  NestModule,
} from '@nestjs/common'
import {ConfigModule} from '@nestjs/config'
import {S3Controller} from './controller/s3.controller'
import {S3Service} from './service/s3.service'
import {OriginAuthMiddleware} from './middleware/origin-auth.middleware'

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
  ],
  controllers: [S3Controller],
  providers: [S3Service],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(OriginAuthMiddleware)
      .forRoutes('*')
  }
}


Then, build the docker image and push it to AWS ECR.

Detail

First, create a .env file with the following information:

  • CDK_DEFAULT_REGION: replace with your desired region
  • CDK_US_REGION: must be us-east-1; this is the fixed region used to create WAF
  • Your BUCKET and IMAGE
  • VERIFY_SECRET: any string you want to use

CDK_DEFAULT_REGION = ap-southeast-1
CDK_US_REGION = us-east-1
BUCKET = <BUCKET>
IMAGE = <IMAGE URI>
VERIFY_SECRET = <VERIFY_SECRET>


Continuing with AWS CDK, create the file lib/eks-manifest-stack.ts:

import { KubectlV34Layer } from "@aws-cdk/lambda-layer-kubectl-v34"
import * as cdk from "aws-cdk-lib"
import * as ec2 from "aws-cdk-lib/aws-ec2"
import * as eks from "aws-cdk-lib/aws-eks"
import * as iam from "aws-cdk-lib/aws-iam"
import * as cr from "aws-cdk-lib/custom-resources"
import { Construct } from "constructs"

export class EksManifestStack extends cdk.Stack {
  readonly lbAddress: string

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props)

    const vpc = new ec2.Vpc(this, "EksVpc", {
      maxAzs: 2,
      natGateways: 0,
      subnetConfiguration: [
        { name: "PublicSubnet", subnetType: ec2.SubnetType.PUBLIC },
      ],
    })

    const cluster = new eks.Cluster(this, "MyEksCluster", {
      vpc,
      vpcSubnets: [{ subnetType: ec2.SubnetType.PUBLIC }],
      defaultCapacity: 0,
      version: eks.KubernetesVersion.V1_34,
      kubectlLayer: new KubectlV34Layer(this, "KubectlLayer"),
      authenticationMode: eks.AuthenticationMode.API_AND_CONFIG_MAP,
      bootstrapClusterCreatorAdminPermissions: true,
    })

    const nodeGroup = cluster.addNodegroupCapacity("PublicNodeGroup", {
      instanceTypes: [new ec2.InstanceType("t3.small")],
      minSize: 1,
      maxSize: 2,
      subnets: { subnetType: ec2.SubnetType.PUBLIC },
      amiType: eks.NodegroupAmiType.AL2023_X86_64_STANDARD,
    })

    const s3Policy = new iam.PolicyStatement({
      actions: ["s3:PutObject", "s3:GetObject", "s3:ListBucket"],
      resources: ["*"],
    })

    const serviceAccount = cluster.addServiceAccount("NestJsServiceAccount", {
      name: "nestjs-s3-sa",
      namespace: "default",
    })
    serviceAccount.addToPrincipalPolicy(s3Policy)

    const nestJsAppResource = new eks.KubernetesManifest(
      this,
      "NestJsAppResources",
      {
        cluster,
        manifest: [
          {
            apiVersion: "apps/v1",
            kind: "Deployment",
            metadata: { name: "nestjs-app", namespace: "default" },
            spec: {
              replicas: 1,
              selector: { matchLabels: { app: "nestjs" } },
              template: {
                metadata: { labels: { app: "nestjs" } },
                spec: {
                  serviceAccountName: serviceAccount.serviceAccountName,
                  containers: [
                    {
                      name: "nestjs-container",
                      image: process.env.IMAGE,
                      ports: [{ containerPort: 3000 }],
                      env: [
                        {
                          name: "REGION",
                          value: process.env.CDK_DEFAULT_REGION,
                        },
                        { name: "BUCKET", value: process.env.BUCKET },
                        {
                          name: "VERIFY_SECRET",
                          value: process.env.VERIFY_SECRET || "",
                        },
                      ],
                    },
                  ],
                },
              },
            },
          },
          {
            apiVersion: "v1",
            kind: "Service",
            metadata: { name: "nestjs-service", namespace: "default" },
            spec: {
              type: "LoadBalancer",
              selector: { app: "nestjs" },
              ports: [{ protocol: "TCP", port: 80, targetPort: 3000 }],
            },
          },
        ],
      },
    )

    nestJsAppResource.node.addDependency(nodeGroup)

    this.lbAddress = cluster.getServiceLoadBalancerAddress("nestjs-service", {
      namespace: "default",
    })

    const getPrefixList = new cr.AwsCustomResource(this, "GetCFPrefixList", {
      onUpdate: {
        service: "EC2",
        action: "describeManagedPrefixLists",
        parameters: {
          Filters: [
            {
              Name: "prefix-list-name",
              Values: ["com.amazonaws.global.cloudfront.origin-facing"],
            },
          ],
        },
        region: this.region,
        physicalResourceId: cr.PhysicalResourceId.of("cf-prefix-list-lookup"),
      },
      policy: cr.AwsCustomResourcePolicy.fromSdkCalls({
        resources: cr.AwsCustomResourcePolicy.ANY_RESOURCE,
      }),
    })
    const cloudFrontPrefixListId = getPrefixList.getResponseField(
      "PrefixLists.0.PrefixListId",
    )
    cluster.clusterSecurityGroup.addIngressRule(
      ec2.Peer.prefixList(cloudFrontPrefixListId),
      ec2.Port.tcp(80),
      "Allow HTTP traffic from CloudFront only",
    )
  }
}

Explanation

  • Creating the vpc, cluster, nodeGroup, s3Policy, serviceAccount, KubernetesManifest, and the addDependency part for the nodeGroup is similar to what I mentioned in previous articles.
  • Note that in this article, lbAddress will be assigned to a variable of this stack instance because we will use this value to create the next stack.
  • AwsCustomResource: used to get the prefix list ID by region for CloudFront. You can understand CloudFront as a collection of edge locations around the world; fetching the prefix list ID by region is equivalent to getting the IP addresses of the edge locations in that region.
  • addIngressRule: adds this prefix list to the cluster to only allow these CloudFront edge locations to access our cluster.


You can use the AWS CLI to view the prefix ID as follows:

$ aws ec2 describe-managed-prefix-lists --filters Name=prefix-list-name,Values=com.amazonaws.global.cloudfront.origin-facing --region {-region} --query "PrefixLists[0].PrefixListId" --output text

# example
$ aws ec2 describe-managed-prefix-lists --filters Name=prefix-list-name,Values=com.amazonaws.global.cloudfront.origin-facing --region ap-southeast-1 --query "PrefixLists[0].PrefixListId" --output text
pl-31a34658


Next, create the file lib/waf-cloudfront-stack.ts:

import * as cdk from "aws-cdk-lib"
import * as cloudfront from "aws-cdk-lib/aws-cloudfront"
import * as origins from "aws-cdk-lib/aws-cloudfront-origins"
import * as eks from "aws-cdk-lib/aws-eks"
import * as wafv2 from "aws-cdk-lib/aws-wafv2"
import { Construct } from "constructs"

export class WafCloudfrontStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: WafCloudfrontStackProps) {
    super(scope, id, props)

    const webAcl = new wafv2.CfnWebACL(this, "AppWaf", {
      defaultAction: { allow: {} },
      scope: "CLOUDFRONT",
      visibilityConfig: {
        cloudWatchMetricsEnabled: true,
        metricName: "WAFMetric",
        sampledRequestsEnabled: true,
      },
      rules: [
        {
          name: "AWS-AWSManagedRulesSQLiRuleSet",
          priority: 1,
          statement: {
            managedRuleGroupStatement: {
              vendorName: "AWS",
              name: "AWSManagedRulesSQLiRuleSet",
            },
          },
          overrideAction: { none: {} },
          visibilityConfig: {
            sampledRequestsEnabled: true,
            cloudWatchMetricsEnabled: true,
            metricName: "SQLiRule",
          },
        },
        {
          name: "AWS-AWSManagedRulesAmazonIpReputationList",
          priority: 2,
          statement: {
            managedRuleGroupStatement: {
              vendorName: "AWS",
              name: "AWSManagedRulesAmazonIpReputationList",
            },
          },
          overrideAction: { none: {} },
          visibilityConfig: {
            sampledRequestsEnabled: true,
            cloudWatchMetricsEnabled: true,
            metricName: "IPReputation",
          },
        },
        {
          name: "General-Rate-Limit",
          priority: 3,
          action: { block: {} },
          statement: {
            rateBasedStatement: {
              limit: 10,
              aggregateKeyType: "IP",
              // evaluationWindowSec: 60, // default 300s = 5m
            },
          },
          visibilityConfig: {
            sampledRequestsEnabled: true,
            cloudWatchMetricsEnabled: true,
            metricName: "GeneralRateLimit",
          },
        },
      ],
    })

    const originVerifySecret = process.env.VERIFY_SECRET || ""
    const cfDist = new cloudfront.Distribution(this, "AppDistribution", {
      defaultBehavior: {
        origin: new origins.HttpOrigin(props.lbAddress, {
          customHeaders: {
            "X-Origin-Verify": originVerifySecret,
          },
          protocolPolicy: cloudfront.OriginProtocolPolicy.HTTP_ONLY,
        }),
        allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
        cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
        originRequestPolicy:
          cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
      },
      webAclId: webAcl.attrArn,
    })

    new cdk.CfnOutput(this, "CloudFrontDomain", {
      value: cfDist.distributionDomainName,
    })
  }
}

interface WafCloudfrontStackProps extends cdk.StackProps {
  readonly lbAddress: string
}

Explanation

  • This WAF will be attached to CloudFront, and I have defined 3 rules:
    • AWSManagedRulesSQLiRuleSet: This rule scans all components of an HTTP Request (Header, Query String, URI, Body). It uses pattern matching algorithms to find signatures of SQL commands such as:
      • Keywords: SELECT, INSERT, UPDATE, DELETE, DROP, UNION.
      • Special characters used to break statements: ' (single quote), -- (comment), ; (end of statement).
      • Logic that is always true: 1=1, 'a'='a'.
      • Strength: It doesn't just block raw code; it can perform transformations on encoded data (like URL-encoded or Hex) before checking, preventing attackers from intentionally "disguising" malicious code.
    • AWSManagedRulesAmazonIpReputationList (Anti-DDoS & Malicious Bots): This is a rule set based on the reputation of IP addresses. AWS owns a massive infrastructure network and collects data on bad IPs worldwide.
      • Blocking method: Instead of analyzing request content, this rule checks if the requesting IP is on Amazon's "blacklist."
      • DDoS Protection: By blocking IPs already identified as attack sources, it helps reduce the load on your system before these junk requests can reach the Backend.
    • General-Rate-Limit: used to block brute force, with limit: 10 being the number of requests per 5 minutes (default); you can change the corresponding time. Note that I only set it to 10 requests for testing purposes; in reality, depending on your product's scale, adjust it accordingly (common values are 500-1000 requests). Remember that employees in companies or organizations may use one or a few shared IP addresses to connect to the Internet; if the request limit you set is too low, when that IP is blocked, all employees there will also be unable to use your service.
  • Cloudfront.Distribution: creating CloudFront is similar to previous articles, just note the following points:
    • props.lbAddress: this is the Load Balancer Address received after successfully creating EksManifestStack.
    • protocolPolicy uses HTTP_ONLY because the Load Balancer is only HTTP.
    • webAclId: points to the ARN of the WAF created above.
    • customHeaders: this part is where the X-Origin-Verify request header is sent to your NestJS application. The reason is that if an attacker knows your Load Balancer address, they could create their own CloudFront to access it. Therefore, we create an additional verify secret to add a private layer of security; requests sent from CloudFront to the Load Balancer with incorrect header information will be denied access.


Next, create the file lib/eks-cloudfront-stack.ts:

import * as cdk from "aws-cdk-lib/core"
import { EksManifestStack } from "./eks-manifest-stack"
import { WafCloudfrontStack } from "./waf-cloudfront-stack"

export const EksCloudfrontStack = (app: cdk.App) => {
  const eksManifestStack = new EksManifestStack(app, "EksManifestStack", {
    env: { region: process.env.CDK_DEFAULT_REGION },
  })
  const wafCloudfrontStack = new WafCloudfrontStack(app, "WafCloudfrontStack", {
    env: { region: process.env.CDK_US_REGION },
    lbAddress: eksManifestStack.lbAddress,
    crossRegionReferences: true,
  })
  wafCloudfrontStack.addDependency(eksManifestStack)
}

  • You can see that it is split into 2 separate stacks in 2 different regions. First, create EksManifestStack in any region you want. Next, look at the addDependency part, which means EksManifestStack must be finished and reach CREATE_COMPLETE status before WafCloudfrontStack begins creation.
  • The reason WAF must be created in us-east-1 (N. Virginia) is that this is the Control Plane for CloudFront to store WAF configurations and push them to all Edge Locations. When a Rule in WAF is created or updated in us-east-1, it receives the change and then propagates that rule set to all CloudFront Edge Locations worldwide.


Update the file bin/aws-cdk.ts:

#!/usr/bin/env node
import 'dotenv/config';
import * as cdk from "aws-cdk-lib/core"
import { EksCloudfrontStack } from "../lib/eks/eks-cloudfront-stack"

const app = new cdk.App()
EksCloudfrontStack(app)


Before deploying, you need to run the bootstrap command because there are two regions that need to be initialized.

$ cdk bootstrap
   Bootstrapping environment aws://347176525761/ap-southeast-1...
   Bootstrapping environment aws://347176525761/us-east-1...
Trusted accounts for deployment: (none)
Trusted accounts for lookup: (none)
   Environment aws://347176525761/ap-southeast-1 bootstrapped (no changes).
Trusted accounts for deployment: (none)
Trusted accounts for lookup: (none)
   Environment aws://347176525761/us-east-1 bootstrapped (no changes).


After deployment, the result is as follows:

 WafCloudfrontStack
 Deployment time: 238.46s

Outputs:
WafCloudfrontStack.CloudFrontDomain = d2sgkl29hqxf17.cloudfront.net

 Total time: 331.33s


Resources created on the AWS Console:



Use Postman to test the API:


Now I will send 50 requests simultaneously to trigger the WAF rule.


AWS WAF checks traffic within a time window (default is 5 minutes or 300 seconds).

  • When blocked: AWS WAF calculates the total number of requests in the previous 5 minutes. If this number is greater than your limit, the IP is blacklisted.
  • When unblocked: Every 30 seconds, WAF recalculates the total requests in the last 5 minutes. As soon as this number drops below the threshold limit, the IP is automatically unblocked.

You can view details of each request to WAF on the console.

Happy coding!

See more articles here.

Comments

Popular posts from this blog

All practice series

Deploying a NodeJS Server on Google Kubernetes Engine

Setting up Kubernetes Dashboard with Kind

Using Kafka with Docker and NodeJS

Monitoring with cAdvisor, Prometheus and Grafana on Docker

Kubernetes Practice Series

Kubernetes Deployment for Zero Downtime

Practicing with Google Cloud Platform - Google Kubernetes Engine to deploy nginx

NodeJS Practice Series

Helm for beginer - Deploy nginx to Google Kubernetes Engine