Sachin Chaurasiya

Cloud Security Part 2 of 5 · Cloud Security Foundations

Cloud Network Security Boundaries: Exposure, Tiers and Default Deny

Deciding what a cloud workload exposes: public and private subnets, security groups that reference each other instead of address ranges, egress control, TLS and DNS, proven with connection tests on a three-tier layout.

Author
Sachin Chaurasiya
Sachin Chaurasiya
Published
Reading time
14 min read
Difficulty
beginner

Reviewed Tested with Docker 29.1 (OrbStack 2.0.5), nginx 1.31.5, Redis 8.2, Terraform 1.14.9, AWS provider 6.64.0, Checkov 3.3.17, OpenSSL 3.6.1

On this page

Overview

Identity decides who may call an API. The network decides who can reach a socket at all, which matters because not every socket checks identity: a cache with no password, a database with a default one, a management port whose login page has a known bug. Network boundaries exist so that those sockets are only reachable from the one place that should reach them.

This part builds the same layout twice. First on a laptop with Docker, where each boundary can be tested with a connection attempt in seconds; then as a Terraform configuration for a cloud provider, validated and scanned but not applied. The Docker version is the one that produced the outputs below; the Terraform version is the shape you would deploy.

Vocabulary, briefly

  • Public exposure means a listener reachable from an address you do not control. On a cloud provider it is a public IP plus a rule allowing inbound from 0.0.0.0/0; on a laptop it is -p 6379:6379, which binds all interfaces.
  • Inbound rules decide who can open a connection to you; outbound (egress) rules decide where your workload can open connections. Almost every provider defaults egress to allow-all, which is the rule attackers rely on to fetch tools and exfiltrate data.
  • Segmentation is putting components on different networks so that reaching one does not mean reaching the others. A subnet is the unit on most providers; a security group (or firewall rule set) is what allows traffic across the boundary.
  • Default deny is the property that no traffic flows unless a rule names it. Providers’ security groups are default-deny inbound already; the mistake is the rule that names everything.
Diagram · Three tiers, one public port
Three tiers, one public portA client on the internet reaches the load balancer on port 443, the only rule that names 0.0.0.0/0. The load balancer forwards to the application tier on port 3000, allowed because the rule references the load balancer group rather than an address range. The application tier reaches the database on port 5432 under the same pattern, and the data subnet has no route to the internet in either direction. A direct connection from the internet to the database port matches no rule and is dropped at the boundary. Application and audit logs leave the private tiers for the log store.Public subnetPrivate subnets · no inbound from the internethttps30005432tcp/5432ClientinternetLoad balancer443 from 0.0.0.0/01Application3000 from sg-lb2Database5432 from sg-app3ScannerinternetDroppedno matching rule4Log storeaudit + access5

A client on the internet reaches the load balancer on port 443, the only rule that names 0.0.0.0/0. The load balancer forwards to the application tier on port 3000, allowed because the rule references the load balancer group rather than an address range. The application tier reaches the database on port 5432 under the same pattern, and the data subnet has no route to the internet in either direction. A direct connection from the internet to the database port matches no rule and is dropped at the boundary. Application and audit logs leave the private tiers for the log store.

  1. The load balancer is the one component with a public address, and its inbound rule is the one rule in the design that names 0.0.0.0/0. It terminates TLS and forwards plain HTTP on the private network.
  2. The application tier accepts port 3000 from the load balancer’s security group, not from a CIDR. If the load balancer is replaced and gets a new address, the rule still holds; if something else appears in the public subnet, the rule does not admit it.
  3. The database accepts 5432 from the application group only and has no egress rule at all. Its subnet has a route table with no default route: even a compromised database process cannot reach the internet.
  4. A scanner on the internet that connects to the database port hits no matching rule and is dropped. There is nothing to log at the database, which is why (5) matters.
  5. Logs leave every tier for a store the tiers cannot modify. Flow logs record the dropped connection; Part 4 covers what to do with them.

The exposed layout, on a laptop

Start with what a first deployment usually looks like: a cache and an application, each published on the host.

docker run -d --name cache-exposed -p 6379:6379 redis:8.2-alpine
docker run -d --name app-exposed -p 3300:3000 ci-demo:1.4.2
docker ps --filter name=exposed --format 'table {{.Names}}\t{{.Ports}}'
NAMES           PORTS
app-exposed     0.0.0.0:3300->3000/tcp, [::]:3300->3000/tcp
cache-exposed   0.0.0.0:6379->6379/tcp, [::]:6379->6379/tcp

0.0.0.0 and [::] mean every interface, including the LAN one. From the host, with no credential:

(printf 'PING\r\nCONFIG GET requirepass\r\n'; sleep 1) | nc 127.0.0.1 6379
+PONG
*2
$11
requirepass
$0

The cache answers, and reports that it has no password. The same nc against the machine’s LAN address succeeded too (Connection to 192.0.2.10 port 6379 succeeded!, address replaced), so anyone on the same network has the same access. On a cloud VM with a security group that allows 0.0.0.0/0, “the same network” is the internet. And the cache container can reach out as freely as it can be reached:

docker exec cache-exposed sh -c 'nc -zv -w 3 example.com 443'
example.com (172.66.147.243:443) open

Nothing here is a Redis problem. It is a placement problem: a component that should only ever hear from the application is listening to the world.

The segmented layout

Same components, three changes: two networks instead of one, one of them internal, and a single published port bound to the loopback interface.

docker network create edge
docker network create --internal backend
docker run -d --name cache --network backend redis:8.2-alpine --requirepass "$(openssl rand -hex 16)"
docker run -d --name app --network backend ci-demo:1.4.2
docker network connect edge app
docker run -d --name proxy --network edge -p 127.0.0.1:8080:80 \
  -v "$PWD/nginx.conf:/etc/nginx/nginx.conf:ro" nginx:latest
events {}
http {
  log_format json escape=json '{"time":"$time_iso8601","remote_addr":"$remote_addr","request":"$request","status":$status,"upstream":"$upstream_addr","ua":"$http_user_agent"}';
  access_log /dev/stdout json;
  server {
    listen 80;
    location / {
      proxy_pass http://app:3000;
      proxy_set_header Host $host;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Proto $scheme;
    }
    # The health endpoint is for the platform, not the internet.
    location /healthz { return 404; }
  }
}
docker ps --filter name='^(proxy|app|cache)$' --format 'table {{.Names}}\t{{.Networks}}\t{{.Ports}}'
NAMES     NETWORKS       PORTS
proxy     edge           127.0.0.1:8080->80/tcp
app       backend,edge   3000/tcp
cache     backend

The application is the only member of both networks, which makes it the only path from the proxy to the cache. Now test each boundary rather than trusting the table:

nc -zv -w 2 127.0.0.1 6379                              # cache from the host
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/version   # app through the proxy
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/healthz   # hidden path
docker exec app sh -c 'nc -zv -w 2 cache 6379'          # app to cache
docker exec cache sh -c 'nc -zv -w 3 example.com 443'   # cache to the internet
nc: connectx to 127.0.0.1 port 6379 (tcp) failed: Connection refused
200
404
cache (192.168.156.2:6379) open
nc: bad address 'example.com'

Four boundaries, four expected results: the cache is not published, the application is reachable only through the proxy, the proxy hides the health endpoint, and the internal network has no DNS and no route out. The proxy’s access log now records every request with the source address and the upstream that served it, which is the first log Part 4 will want.

The boundary that was not there

The fifth test is the one that makes the section worth reading. The proxy is on edge only and the cache on backend only; by name the cache does not resolve from the proxy, and a connection to cache:6379 times out. By address it is a different story:

docker exec proxy bash -c 'exec 3<>/dev/tcp/192.168.156.2/6379; printf "PING\r\n" >&3; timeout 2 head -c 20 <&3'
-NOAUTH Authentication required.

The cache answered. On this host (OrbStack 2.0.5 on macOS, Docker Engine 28.5) containers on different bridge networks can reach each other by IP address; the --internal flag removed DNS and the default route, not the path between bridges. Docker Engine on Linux inserts DOCKER-ISOLATION iptables rules that drop exactly this traffic, which is why the pattern is normally described as segmentation; that behaviour was not verified in this run, because the host was not Linux.

Two lessons, both general. A network boundary is a claim about an implementation you did not write; prove it with a connection test from the wrong side, on the platform you actually run. And the reason the cache returned NOAUTH instead of PONG is that Part 1’s control was in place: the --requirepass set when the container started. Identity held where the network did not. That is what defence in depth means in practice, and it is the reason none of the five parts of this path is optional.

The same layout as Terraform

The Docker layout maps onto a cloud provider one to one: a public subnet holding the load balancer, a private subnet for the application, an isolated subnet for the database, and three security groups whose rules reference each other. The configuration below uses the AWS provider, because the Infrastructure as Code Security path already established that provider’s conventions; the design is identical on any provider with subnets, route tables and reference-based firewall rules.

resource "aws_subnet" "data" {
  vpc_id            = aws_vpc.app.id
  cidr_block        = "10.20.20.0/24"
  availability_zone = "eu-west-1a"
}

# The data subnet gets a route table with no default route at all.
resource "aws_route_table" "data" {
  vpc_id = aws_vpc.app.id
}

resource "aws_vpc_security_group_ingress_rule" "lb_https" {
  security_group_id = aws_security_group.lb.id
  description       = "HTTPS from anywhere: this is the one intentional public port"
  ip_protocol       = "tcp"
  from_port         = 443
  to_port           = 443
  cidr_ipv4         = "0.0.0.0/0"
}

resource "aws_vpc_security_group_ingress_rule" "app_from_lb" {
  security_group_id            = aws_security_group.app.id
  description                  = "Application port from the load balancer"
  ip_protocol                  = "tcp"
  from_port                    = 3000
  to_port                      = 3000
  referenced_security_group_id = aws_security_group.lb.id
}

resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
  security_group_id            = aws_security_group.db.id
  description                  = "PostgreSQL from the application tier"
  ip_protocol                  = "tcp"
  from_port                    = 5432
  to_port                      = 5432
  referenced_security_group_id = aws_security_group.app.id
}

# The VPC's default security group allows all traffic between its members.
# Managing it as an empty group removes that implicit path.
resource "aws_default_security_group" "empty" {
  vpc_id = aws_vpc.app.id
}

Three decisions in that file carry the design. referenced_security_group_id instead of cidr_ipv4 on every internal rule: membership of a group is what admits traffic, so addresses can change and new resources in a subnet are not admitted by accident. A route table with no 0.0.0.0/0 route for the data subnet: the database cannot reach the internet even if someone later adds an egress rule. And an explicitly empty default security group: on AWS the default group allows all traffic between its members, and every instance launched without a group lands in it.

The full file (VPC, three subnets, gateway, route tables, three groups with their egress rules) was formatted, validated and scanned:

docker run --rm -v "$PWD:/tf" -w /tf hashicorp/terraform:1.14 validate
docker run --rm -v "$PWD:/tf" -w /tf bridgecrew/checkov:3.3.17 -d /tf --framework terraform --quiet --compact
Success! The configuration is valid.

Passed checks: 48, Failed checks: 4, Skipped checks: 0
Check: CKV2_AWS_5: "Ensure that Security Groups are attached to another resource"
	FAILED for resource: aws_security_group.lb        (and .app, .db)
Check: CKV2_AWS_11: "Ensure VPC flow logging is enabled in all VPCs"
	FAILED for resource: aws_vpc.app

Both remaining findings are accurate. The groups are unattached because the example declares no instances or load balancer; in a real module they attach to those resources. Flow logging is missing, and Part 4 is where it belongs. The configuration was not applied to an account, so what a provider does with these resources was not observed here; the exposed patterns this design replaces (SSH from 0.0.0.0/0, a publicly accessible database) are scanned and fixed in Scan Infrastructure as Code with Checkov.

TLS termination and DNS

Where TLS ends is a boundary decision. In the diagram it ends at the load balancer: certificates live in one place, the application speaks plain HTTP on a private network it shares with nothing else, and the proxy adds X-Forwarded-Proto so the application can still refuse to set cookies on a request that arrived over HTTP. Terminating at an edge network (Cloudflare in front of the origin) moves the boundary further out and hides the origin address entirely; Hardening a Static Site on Cloudflare covers the zone settings behind it.

What the edge enforces can be checked from outside with nothing but OpenSSL, against this site:

echo | openssl s_client -connect sachinchaurasiya.com:443 -servername sachinchaurasiya.com -brief
echo | openssl s_client -connect sachinchaurasiya.com:443 -servername sachinchaurasiya.com -tls1_1 -cipher 'DEFAULT:@SECLEVEL=0'
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Peer certificate: CN=sachinchaurasiya.com
Verification: OK
Negotiated TLS1.3 group: X25519MLKEM768

error:0A00042E:SSL routines:ssl3_read_bytes:tlsv1 alert protocol version

The second command forces the client down to TLS 1.1 (OpenSSL 3.6 refuses to even offer it at the default security level, hence SECLEVEL=0) and the server sends alert 70, protocol version: the minimum-TLS-1.2 setting is enforced, not merely configured. The TLS debugging toolbox page has the longer list of checks.

DNS is part of the exposure surface in two ways. What a name resolves to tells an attacker where the origin is: dig +short sachinchaurasiya.com returns two anycast addresses in Cloudflare’s ranges, not a server. And a name that resolves to a private address from the internet (db.internal published in a public zone) is a map of your internal network handed out for free. Keep internal names in a private zone the public resolver cannot see.

Management interfaces

SSH, RDP, a database console, the Kubernetes API server, the cloud provider’s own console: each is a listener whose compromise is the whole system. The rules that follow from the sections above:

  • No management port accepts inbound from 0.0.0.0/0. Not with a strong password, not with key-only auth; the point is that the login code should not be reachable to have bugs in.
  • Reach management interfaces through an identity-aware path: the provider’s session manager, a bastion behind SSO, or a zero-trust proxy that authenticates the person before a TCP connection is opened. The admin console of this site runs behind exactly that kind of access layer rather than behind a password.
  • The Kubernetes API endpoint is a management interface. A managed cluster with a public endpoint and 0.0.0.0/0 in its authorised networks is a control plane on the internet, protected by whatever RBAC allows system:anonymous and system:authenticated.

Security implications

  • Reference-based rules make the security group the identity of a tier. Attaching the application group to a debugging instance gives that instance the database, which is the correct place for that decision to be visible in review.
  • --internal networks and no-default-route subnets stop exfiltration by the workload itself; they do nothing about exfiltration through the application’s legitimate outbound (the app_https_out rule). DNS and HTTPS egress filtering are the next layer and are out of scope for this part.
  • A hidden health endpoint (/healthz → 404) removes information, not risk. It is worth doing because version strings and dependency states are reconnaissance, not because it stops an attack.
  • Every boundary above was proved by a connection attempt. The one that was assumed (bridge isolation) failed. Test from the wrong side; a diagram is a hypothesis.

Troubleshooting

SymptomCauseFix
Container on an internal network cannot resolve any name--internal networks have no embedded DNS forwarder and no routeExpected; reach it by name only from a container that shares the network
A published port is reachable from the LAN-p 8080:80 binds 0.0.0.0Bind loopback (-p 127.0.0.1:8080:80) or put the listener behind the proxy
Two tiers can talk although no rule allows itDefault security group still attached, or host lacks isolationManage the default group as empty; test cross-bridge traffic on the real host
s_client fails with no protocols availableLocal OpenSSL refuses the old protocol before the server sees itAdd -cipher 'DEFAULT:@SECLEVEL=0' to test the server, not the client
Checkov CKV2_AWS_5 on every security groupGroups declared in a module with no instancesExpected in a network-only module; the check passes once the consumers are declared

Running this in production

  • Write the exposure inventory first: every public address, every port open to 0.0.0.0/0, and the reason. The list should fit on one screen; if it does not, that is the finding.
  • Make 0.0.0.0/0 a reviewed exception. A Checkov or OPA rule that fails any inbound rule with that CIDR except on ports 80 and 443 of a load balancer keeps the inventory honest without a meeting.
  • Put flow logs on the VPC before it carries traffic, so the baseline exists before the incident.
  • Re-run the connection tests after every network change, from a host outside the boundary. The five tests in this part take under a minute and would have caught the isolation gap on this laptop.

References

Keep reading