SSRF Allowlists and Cloud Metadata Service Protection
SSRF attacks that reach cloud metadata endpoints are not theoretical — they are the mechanism behind several high-profile cloud account takeovers. An attacker who can coerce your application into fetching http://169.254.169.254/latest/meta-data/iam/security-credentials/ retrieves temporary IAM credentials and can pivot to full cloud account access within seconds. Traditional perimeter controls and regex denylists cannot stop this: encoding bypasses, DNS rebinding, and IPv6 translation variants defeat them reliably. The only defensible baseline is strict parsed-URI allowlisting combined with mandatory metadata service session enforcement.
This guide is a focused implementation reference for the SSRF Prevention cluster, which sits within the Vulnerability Patterns & Web Mitigation Strategies section of this site. It pairs naturally with the injection attack prevention patterns when auditing outbound request construction, and with secure HTTP header configuration for defence-in-depth at the transport layer.
Prerequisites
- Cloud-hosted application (AWS EC2/ECS/Lambda, Azure VM/ACI, or GCP Compute/GKE) making outbound HTTP requests driven by user-supplied input
- Infrastructure managed with Terraform (or a comparable IaC tool)
- CI/CD pipeline capable of running OPA/
opa eval,tfsec, orcheckov - Python 3.9+ or a language with an equivalent canonical IP-address library
Expected Outcomes
- All outbound HTTP requests from your application pass through a validated allowlist that rejects private, link-local, and loopback addresses after DNS resolution
- IMDSv1 is disabled across every EC2 instance; equivalent session-token enforcement is active on Azure and GCP
- A CI/CD policy gate blocks any IaC change that weakens these controls before it reaches production
- Audit-grade structured logs capture every rejected outbound request
Step 1 — Build a Strict Parsed-URI Allowlist with DNS Pinning
The root cause of most SSRF bypasses is that developers validate the hostname string but allow the HTTP client to re-resolve DNS at connection time. DNS rebinding and redirect-chaining exploit exactly this gap.
The correct pattern resolves DNS once inside your validation function, verifies the resolved IP against a blocklist of forbidden ranges, and then constructs the outbound request URL using the resolved IP — not the original hostname — while preserving the Host header for TLS SNI and virtual-host routing.
import socket
import ipaddress
import urllib.parse
from typing import Set, Tuple
# Only these FQDNs are trusted outbound targets
ALLOWED_DOMAINS: Set[str] = {"api.trusted-partner.com", "cdn.internal-corp.net"}
# Reject every IP that falls in any of these ranges
BLOCKED_NETWORKS = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"), # AWS/Azure/GCP metadata link-local
ipaddress.ip_network("100.64.0.0/10"), # Carrier-grade NAT (RFC 6598)
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
ipaddress.ip_network("::ffff:0:0/96"), # IPv4-mapped IPv6 (covers ::ffff:169.254.x.x)
]
def _is_ip_blocked(ip_str: str) -> bool:
try:
ip_obj = ipaddress.ip_address(ip_str)
return any(ip_obj in net for net in BLOCKED_NETWORKS)
except ValueError:
return True # Reject any address we cannot parse
def validate_and_pin_request(user_url: str) -> Tuple[str, dict]:
"""
Returns (safe_url, headers) where safe_url uses the resolved IP.
Raises ValueError / RuntimeError on any validation failure.
"""
parsed = urllib.parse.urlparse(user_url)
# 1. Scheme: HTTPS only
if parsed.scheme.lower() != "https":
raise ValueError("Only HTTPS is permitted")
# 2. Domain allowlist — lowercase, strip port
domain = (parsed.hostname or "").lower().encode("idna").decode("ascii")
if domain not in ALLOWED_DOMAINS:
raise ValueError(f"Domain '{domain}' not in allowlist")
# 3. Resolve DNS before any socket is opened
try:
results = socket.getaddrinfo(domain, 443, socket.AF_UNSPEC, socket.SOCK_STREAM)
if not results:
raise RuntimeError("DNS returned no results")
resolved_ip = results[0][4][0]
except socket.gaierror as exc:
raise RuntimeError(f"DNS resolution failed: {exc}") from exc
# 4. Block any resolved IP in restricted ranges
if _is_ip_blocked(resolved_ip):
raise ValueError(f"Resolved IP {resolved_ip} is in a blocked range")
# 5. Pin the connection to the resolved IP; preserve Host for SNI
path = parsed.path or "/"
safe_url = f"https://{resolved_ip}{path}"
if parsed.query:
safe_url += f"?{parsed.query}"
headers = {"Host": domain}
return safe_url, headers
Three things make this resistant to common bypasses:
- Punycode conversion (
encode("idna")) before the allowlist check prevents homograph attacks using look-alike Unicode characters. ::ffff:0:0/96in the blocklist catches IPv4-mapped IPv6 addresses.ipaddress.ip_address("::ffff:169.254.169.254")will match this network, so the metadata address is blocked even when clients supply it in IPv6 notation.- Using the resolved IP in the request URL prevents the HTTP client library from performing a second DNS lookup that a rebinding attack could redirect.
Never pass user_url directly to requests.get() or httpx.get(). Always go through validate_and_pin_request first.
Step 2 — Enforce Metadata Service Session Tokens in Terraform
Application-layer allowlists reduce but do not eliminate risk — a misconfigured redirect handler or a third-party library that bypasses your wrapper can still reach the metadata endpoint. Defence in depth requires disabling unauthenticated metadata access at the infrastructure layer.
The diagram below shows the two control planes that must both be active:
AWS — IMDSv2 via Terraform
resource "aws_instance" "secure_app" {
ami = var.ami_id
instance_type = "t3.medium"
metadata_options {
http_endpoint = "enabled"
http_tokens = "required" # Disables IMDSv1 — CRITICAL
http_put_response_hop_limit = 1 # Prevents container-escape pivot
instance_metadata_tags = "disabled"
}
tags = { Name = "secure-app" }
}
http_tokens = "required" forces every metadata GET to supply an X-aws-ec2-metadata-token obtained via a prior PUT to http://169.254.169.254/latest/api/token. A single-hop SSRF request from within the application cannot satisfy this flow and receives a 401 Unauthorized from the metadata endpoint.
http_put_response_hop_limit = 1 means the PUT token request is not routable from inside a container running on the host; container workloads must use IAM roles for service accounts or IAM Roles Anywhere instead.
Azure — Enforce Managed Identity, Remove Static Credentials
resource "azurerm_linux_virtual_machine" "secure_vm" {
name = "secure-vm"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
size = "Standard_B2s"
admin_username = "azureadmin"
identity {
type = "SystemAssigned" # Token-gated IMDS; no static credentials on disk
}
admin_ssh_key {
username = "azureadmin"
public_key = file(var.ssh_public_key_path)
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "UbuntuServer"
sku = "20_04-lts"
version = "latest"
}
}
Azure IMDS at 169.254.169.254 requires a Metadata: true request header and returns tokens scoped to the managed identity. Unlike AWS IMDSv1, there is no anonymous credential retrieval path, but you still need to ensure no static service-principal secrets are stored in the VM environment.
GCP — Minimal Scopes and OS Login
resource "google_compute_instance" "secure_instance" {
name = "secure-instance"
machine_type = "e2-medium"
zone = "us-central1-a"
metadata = {
enable-oslogin = "TRUE"
block-project-ssh-keys = "true"
}
service_account {
email = google_service_account.app.email
scopes = ["https://www.googleapis.com/auth/cloud-platform"]
}
boot_disk {
initialize_params { image = "debian-cloud/debian-11" }
}
network_interface {
network = var.vpc_network
subnetwork = var.vpc_subnetwork
# No access_config block = no external IP; reduces metadata exposure surface
}
}
GCP metadata at metadata.google.internal (resolves to 169.254.169.254) requires a Metadata-Flavor: Google header. Limiting service account scopes ensures that even if an SSRF reaches the endpoint, the token grants only the permissions the workload actually needs.
Step 3 — Gate IaC Changes with OPA/Rego in CI/CD
Shift-left enforcement catches misconfigurations before they reach production. The following OPA policy blocks any Terraform plan that enables IMDSv1 or sets a hop limit above 1 on AWS instances.
package terraform.metadata_security
import rego.v1
# Block AWS instances that permit IMDSv1
deny contains msg if {
resource := input.planned_values.root_module.resources[_]
resource.type == "aws_instance"
resource.values.metadata_options[_].http_tokens != "required"
msg := sprintf(
"SSRF RISK — '%s' does not enforce IMDSv2 (http_tokens must be 'required').",
[resource.address]
)
}
# Block AWS instances where hop_limit allows container escape
deny contains msg if {
resource := input.planned_values.root_module.resources[_]
resource.type == "aws_instance"
resource.values.metadata_options[_].http_put_response_hop_limit > 1
msg := sprintf(
"SSRF RISK — '%s' hop_limit > 1 allows container-escape SSRF.",
[resource.address]
)
}
Integrate this into your pipeline:
# .github/workflows/security-gates.yml (GitHub Actions excerpt)
- name: Terraform plan (JSON)
run: terraform plan -out=tfplan.binary && terraform show -json tfplan.binary > tfplan.json
- name: OPA — metadata SSRF policy
run: |
opa eval \
--data policies/metadata_security.rego \
--input tfplan.json \
--format pretty \
"data.terraform.metadata_security.deny" \
| tee opa_output.txt
if grep -q '"msg"' opa_output.txt; then
echo "OPA policy violations found — blocking merge"
exit 1
fi
- name: tfsec — CIS metadata checks
uses: aquasecurity/tfsec-[email protected]
with:
soft_fail: false
additional_args: "--include-ignored-checks AWS079"
For SAST coverage, add a Semgrep rule that flags HTTP client calls where the URL argument traces back to a request parameter without passing through your validation function:
# semgrep-rules/ssrf-unvalidated-url.yaml
rules:
- id: ssrf-unvalidated-url
patterns:
- pattern: requests.get($URL, ...)
- pattern-not: requests.get(validate_and_pin_request(...), ...)
message: "SSRF: requests.get() called with potentially unvalidated URL '$URL'"
languages: [python]
severity: ERROR
Verification
After deploying these controls, run three checks to confirm they are active.
Check 1 — Application allowlist rejects metadata addresses
# Run in a Python REPL or test suite on the deployed host
from your_module import validate_and_pin_request
for probe in [
"http://169.254.169.254/latest/meta-data/",
"https://169.254.169.254/latest/meta-data/",
"https://::ffff:169.254.169.254/",
"https://attacker.evil.com/",
]:
try:
validate_and_pin_request(probe)
print(f"FAIL — should have been rejected: {probe}")
except (ValueError, RuntimeError) as e:
print(f"OK — rejected: {probe!r} ({e})")
Every probe should print OK.
Check 2 — IMDSv2 enforcement on EC2
Run this from within the instance (or a canary container in the same ECS task):
# IMDSv1 GET must fail with 401
curl -s -o /dev/null -w "%{http_code}" http://169.254.169.254/latest/meta-data/
# Expected: 401
# IMDSv2 PUT + GET must succeed
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/iam/security-credentials/
# Expected: role name JSON (proves the token flow works for legitimate use)
Check 3 — OPA policy blocks a deliberately bad plan
# Create a plan with IMDSv1 enabled, convert to JSON, then run OPA
terraform plan -var 'http_tokens=optional' -out bad.plan
terraform show -json bad.plan > bad.json
opa eval --data policies/metadata_security.rego --input bad.json \
"data.terraform.metadata_security.deny"
# Expected: at least one deny message in the output
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
401 Unauthorized from metadata on a new EC2 instance |
Correct — IMDSv2 is enforced. Application code is still using a raw GET. | Update SDK calls to use boto3 with the default credential chain, which handles IMDSv2 automatically. |
validate_and_pin_request rejects a known-good internal API domain |
Domain missing from ALLOWED_DOMAINS or resolved IP falls in a private CIDR. |
Add the FQDN to ALLOWED_DOMAINS; if the API lives on RFC 1918 space, route it through a DMZ proxy with a public IP. |
OPA policy passes a plan that has no metadata_options block |
Terraform omits unchanged blocks from the planned JSON. | Reference null_resource or use terraform plan -refresh-only to force the block to appear; alternatively check for absence of the block in the Rego rule. |
| SSRF canary container can still reach metadata endpoint | http_put_response_hop_limit is set to 2 or higher. |
Set http_put_response_hop_limit = 1 and redeploy; verify with aws ec2 describe-instances --query '...metadata_options'. |
::ffff:169.254.169.254 slips past the IP blocklist |
BLOCKED_NETWORKS list is missing the ::ffff:0:0/96 entry. |
Add ipaddress.ip_network("::ffff:0:0/96") to BLOCKED_NETWORKS; the ipaddress module handles IPv4-mapped addresses correctly in range checks. |
Related
- Server-Side Request Forgery (SSRF) Prevention — parent cluster covering SSRF attack anatomy, bypass patterns, and the full mitigation architecture
- Injection Attack Prevention — parallel controls for input validation when constructing queries and commands
- Parameterized Queries for SQL and NoSQL Injection — analogous “never use raw user input” principle applied to database drivers
- Vulnerability Patterns & Web Mitigation Strategies — section index covering XSS, CSRF, injection, DOM vulnerabilities, and secure headers alongside SSRF
Frequently Asked Questions
Why are denylists insufficient for SSRF mitigation against metadata services?
Denylists fail because attackers can bypass them with encoding tricks (%2569.%2525%2565%2566.%2569%2570), DNS rebinding (where a controlled domain first resolves to a public IP that passes the check, then re-resolves to 169.254.169.254 at connection time), IPv4-mapped IPv6 notation (::ffff:169.254.169.254), and newly provisioned internal CIDRs that were not in the list when it was written. An allowlist inverts this: instead of enumerating all malicious inputs, it enumerates the small set of legitimate destinations.
How do I prevent DNS rebinding when validating SSRF allowlists?
Resolve the domain to an IP inside your validation function, verify the IP is outside all blocked ranges, and then construct the outbound request URL using the resolved IP directly while preserving the original Host header. This pins the TCP connection to the validated address and prevents the HTTP client library from triggering a second DNS lookup — which a rebinding attack would redirect to the metadata address.
What compliance frameworks mandate metadata service protection?
SOC 2 CC6.1 and CC7.2 require network segmentation and monitoring of privileged credential access. OWASP ASVS 4.0 V10.3 explicitly covers server-side request forgery controls. ISO 27001 A.13.1.1 mandates network-layer controls preventing unauthorised internal access. PCI-DSS 4.0 Requirements 1.3.1 and 2.2.7 require least-privilege IAM and explicit metadata endpoint hardening. The CIS AWS Foundations Benchmark v1.5 check EC2.8 requires http_tokens = "required" on all instances.