WAF++ WAF++
Back to WAF++ Homepage

Best Practice: Network Security & Segmentation

Context

Network segmentation is the technical implementation of the defense-in-depth principle at the network level. Without segmentation, a compromised system can reach all other systems in the same network.

Typical misconfigurations:

  • Flat network: all resources in one VPC without subnet separation

  • Security groups with 0.0.0.0/0 on management ports (SSH 22, RDP 3389)

  • Databases in public subnets with publicly_accessible = true

  • Missing VPC flow logs (no network forensics possible)

  • S3, KMS, SQS accessed over the internet instead of VPC endpoints

  • WAF-SEC-050 – Network Segmentation & Security Group Hardening

VPC Design: Private by Default

# Three-tier VPC design for production workloads
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "prod-vpc"
  }
}

# Public subnets: ONLY for load balancers and NAT gateways
resource "aws_subnet" "public" {
  for_each = {
    "a" = "10.0.0.0/24"
    "b" = "10.0.1.0/24"
  }

  vpc_id                  = aws_vpc.main.id
  cidr_block              = each.value
  availability_zone       = "eu-central-1${each.key}"
  map_public_ip_on_launch = false  # No automatic public IPs!

  tags = { Name = "public-${each.key}" }
}

# Private subnets: Application tier
resource "aws_subnet" "private_app" {
  for_each = {
    "a" = "10.0.10.0/24"
    "b" = "10.0.11.0/24"
  }

  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value
  availability_zone = "eu-central-1${each.key}"

  tags = { Name = "private-app-${each.key}" }
}

# Private subnets: Data tier (isolated!)
resource "aws_subnet" "private_data" {
  for_each = {
    "a" = "10.0.20.0/24"
    "b" = "10.0.21.0/24"
  }

  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value
  availability_zone = "eu-central-1${each.key}"

  tags = { Name = "private-data-${each.key}" }
}

Security Group Strategy: Application-First

Security groups are structured by application logic, not port numbers:

# ALB security group: HTTPS only from internet
resource "aws_security_group" "alb" {
  name        = "alb-public"
  description = "Allow HTTPS inbound from internet to ALB"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "HTTPS from internet"
  }

  # No open egress – only to app tier
  egress {
    from_port       = 8080
    to_port         = 8080
    protocol        = "tcp"
    security_groups = [aws_security_group.app.id]
    description     = "Forward to application tier"
  }
}

# Application security group: only from ALB
resource "aws_security_group" "app" {
  name        = "app-tier"
  description = "Application tier: only from ALB"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port       = 8080
    to_port         = 8080
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
    description     = "From ALB only"
  }

  # Egress: only to database and AWS services (via VPC endpoint)
  egress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.db.id]
    description     = "To PostgreSQL"
  }
}

# Database security group: only from application tier
resource "aws_security_group" "db" {
  name        = "db-tier"
  description = "Database tier: only from app tier"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.app.id]
    description     = "From application tier only"
  }
  # No egress needed for database tier
}
Compliant Non-Compliant
# SSH via SSM only (no port 22 open)
# No SSH port in security group
resource "aws_security_group" "app" {
  ingress {
    from_port       = 8080
    to_port         = 8080
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }
}
# PROHIBITED: SSH from internet
resource "aws_security_group" "app" {
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]  # Critical!
  }
}

NACLs as a Second Layer of Defense

Network ACLs (NACLs) operate at the subnet level and complement security groups:

# NACL for data tier subnets: only from app tier
resource "aws_network_acl" "data_tier" {
  vpc_id     = aws_vpc.main.id
  subnet_ids = [for subnet in aws_subnet.private_data : subnet.id]

  # Inbound: only from app tier CIDR
  ingress {
    rule_no    = 100
    action     = "allow"
    protocol   = "tcp"
    from_port  = 5432
    to_port    = 5432
    cidr_block = "10.0.10.0/23"  # App tier subnets
  }

  # Deny all other inbound connections
  ingress {
    rule_no    = 32766
    action     = "deny"
    protocol   = "-1"
    from_port  = 0
    to_port    = 0
    cidr_block = "0.0.0.0/0"
  }

  # Outbound: only response traffic (ephemeral ports)
  egress {
    rule_no    = 100
    action     = "allow"
    protocol   = "tcp"
    from_port  = 1024
    to_port    = 65535
    cidr_block = "10.0.10.0/23"
  }

  egress {
    rule_no    = 32766
    action     = "deny"
    protocol   = "-1"
    from_port  = 0
    to_port    = 0
    cidr_block = "0.0.0.0/0"
  }
}

VPC Flow Logs: Network Forensics

VPC flow logs record all network connections and are indispensable for security forensics and anomaly detection:

# CloudWatch log group for VPC flow logs
resource "aws_cloudwatch_log_group" "vpc_flow_logs" {
  name              = "/aws/vpc/flow-logs/prod"
  retention_in_days = 90
  kms_key_id        = aws_kms_key.logs.arn  # Encrypt with CMK
}

# IAM role for flow logs
resource "aws_iam_role" "flow_logs" {
  name = "vpc-flow-logs-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "vpc-flow-logs.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

# Enable VPC flow logs (all traffic types)
resource "aws_flow_log" "main" {
  vpc_id          = aws_vpc.main.id
  traffic_type    = "ALL"  # ACCEPT, REJECT, or ALL
  iam_role_arn    = aws_iam_role.flow_logs.arn
  log_destination = aws_cloudwatch_log_group.vpc_flow_logs.arn

  # Extended fields for better forensics
  log_format = "$${version} $${account-id} $${interface-id} $${srcaddr} $${dstaddr} $${srcport} $${dstport} $${protocol} $${packets} $${bytes} $${start} $${end} $${action} $${log-status} $${vpc-id} $${subnet-id} $${instance-id} $${tcp-flags}"
}

VPC Endpoints: Egress via AWS Backbone

Instead of accessing S3, KMS, SQS over the public internet, VPC endpoints are used:

# Gateway endpoint for S3 (free, no NAT gateway needed)
resource "aws_vpc_endpoint" "s3" {
  vpc_id            = aws_vpc.main.id
  service_name      = "com.amazonaws.eu-central-1.s3"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = [aws_route_table.private.id]
}

# Interface endpoint for Secrets Manager
resource "aws_vpc_endpoint" "secrets_manager" {
  vpc_id              = aws_vpc.main.id
  service_name        = "com.amazonaws.eu-central-1.secretsmanager"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = [for subnet in aws_subnet.private_app : subnet.id]
  security_group_ids  = [aws_security_group.vpc_endpoints.id]
  private_dns_enabled = true
}

# Interface endpoint for KMS
resource "aws_vpc_endpoint" "kms" {
  vpc_id              = aws_vpc.main.id
  service_name        = "com.amazonaws.eu-central-1.kms"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = [for subnet in aws_subnet.private_app : subnet.id]
  security_group_ids  = [aws_security_group.vpc_endpoints.id]
  private_dns_enabled = true
}

Benefits of VPC endpoints:

  • No NAT gateway traffic → direct cost savings

  • Traffic never leaves the AWS network → better data sovereignty

  • Restrictive egress policies possible (security groups without internet egress)

Anti-Patterns

  • Flat network: One subnet for everything – no isolation possible.

  • Open security groups: 0.0.0.0/0 on SSH, RDP, or admin ports.

  • Missing VPC flow logs: No network forensics possible after incidents.

  • Databases without publicly_accessible = false: Publicly reachable databases are unacceptable.

  • No NAT gateway for private subnets: Workaround via public IPs is worse.

  • Too broad security group egress: 0.0.0.0/0 egress opens unnecessary attack surface.

Metrics

  • Security groups with 0.0.0.0/0 on management ports: Target: 0

  • VPCs without flow logs: Target: 0

  • Publicly reachable databases (publicly_accessible = true): Target: 0

  • VPC endpoints for used AWS services: Target: 100% (S3, KMS, Secrets Manager)