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.
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.
- 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. - 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.
- 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.
- 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.
- 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/0in its authorised networks is a control plane on the internet, protected by whatever RBAC allowssystem:anonymousandsystem: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.
--internalnetworks and no-default-route subnets stop exfiltration by the workload itself; they do nothing about exfiltration through the application’s legitimate outbound (theapp_https_outrule). 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
| Symptom | Cause | Fix |
|---|---|---|
| Container on an internal network cannot resolve any name | --internal networks have no embedded DNS forwarder and no route | Expected; 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.0 | Bind loopback (-p 127.0.0.1:8080:80) or put the listener behind the proxy |
| Two tiers can talk although no rule allows it | Default security group still attached, or host lacks isolation | Manage the default group as empty; test cross-bridge traffic on the real host |
s_client fails with no protocols available | Local OpenSSL refuses the old protocol before the server sees it | Add -cipher 'DEFAULT:@SECLEVEL=0' to test the server, not the client |
Checkov CKV2_AWS_5 on every security group | Groups declared in a module with no instances | Expected 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/0a 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