SSRF Prevention skill for Codex
Check the IP you connect to, not the URL. Codex finds every place the server fetches a URL a user can influence, including webhooks, link previews, PDF renderers and file parsers, and adds a check on the IP the socket actually connects to. That defeats DNS rebinding, alternate IP encodings and redirect tricks. It also limits schemes, routes egress through a filtering proxy, and hardens cloud metadata with IMDSv2.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- ssrf-prevention Installs to ~/.agents/skills/ssrf-prevention/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install ssrf-prevention The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project ssrf-prevention Installs to .agents/skills/ssrf-prevention/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/ssrf-prevention.zipunzip -o ssrf-prevention.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) ssrf-prevention PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $ssrf-prevention, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Defends features that fetch user-supplied URLs (webhooks, link previews, image import, PDF rendering, OAuth/OIDC discovery) against server-side request forgery by validating the resolved IP at connect time, blocking private, loopback, link-local and cloud metadata ranges in IPv4 and IPv6, revalidating redirects, and routing egress through a filtering proxy. Covers AWS IMDSv2, GCP and Azure metadata. Use when code makes outbound HTTP requests to URLs influenced by users or when reviewing a reported SSRF.
Use it when
- Code fetches URLs supplied or influenced by users
- Building webhooks, link unfurling, image import or URL-to-PDF
- A pentest or bug bounty reported SSRF
- Hardening cloud instances against metadata credential theft
Use something else when
- You are building the webhook sender itself, not securing it: use webhook-delivery
- You want a cloud-wide misconfiguration review: use cloud-security-posture
How to check the result
Tests show loopback, private, link-local, IPv4-mapped IPv6, decimal-encoded and redirect-to-internal URLs are all rejected while a normal public HTTPS URL succeeds.
The mistake it prevents
Validating the hostname string or a first DNS lookup, then letting the HTTP client resolve again, which DNS rebinding and redirects bypass.
Done well means: no user-influenced URL can make the server talk to loopback, the internal network or a cloud metadata service, regardless of encoding tricks, DNS rebinding or redirects, and the check happens on the IP actually connected to, not on the string the user typed.
Find the fetchers
rg -n "requests\.(get|post|request)|httpx\.|urllib\.request|urlopen|aiohttp\.ClientSession" --type py
rg -n "fetch\(|axios\.|got\(|undici|http\.get\(|https\.get\(|needle\(|superagent" --type js --type ts
rg -n "http\.(Get|Post|NewRequest)|\.Do\(req" --type go
rg -n -i "webhook|callback_url|avatar_url|image_url|import.*url|preview|unfurl|oembed|wkhtmltopdf|puppeteer|playwright|chromium|ImageMagick|ffmpeg|jwks_uri|discovery" -g '!node_modules'
Indirect fetchers matter as much as HTTP clients: headless browsers rendering user HTML
into PDFs, ImageMagick and ffmpeg reading URLs inside files, XML parsers resolving
external entities, SVG <image href>, and OIDC discovery following an issuer the user
configured.
Decide the control
| Feature | Recommended control |
|---|---|
| Fetch from a known partner set (payment provider, storage) | Exact host allowlist; no user-chosen host at all |
| User-configured webhooks or link previews (any public host) | Deny-by-IP at connect time + redirect revalidation + egress proxy |
| HTML-to-PDF / screenshot of user content | Run the renderer in an isolated network namespace or container with egress only via the proxy; block file:// |
| File parsers (images, video, XML) | Disable remote fetching in the parser (ImageMagick policy, ffmpeg -protocol_whitelist file, XML parsers with DTDs off) |
| Cloud instance | IMDSv2 required with hop limit 1 (AWS), metadata header checks (GCP/Azure) as defence in depth |
An allowlist of destinations always beats a denylist. Use the denylist only when the product genuinely needs arbitrary public URLs.
Blocked ranges
Block when the resolved address is not globally routable, including:
| Range | Why |
|---|---|
127.0.0.0/8, ::1 | Loopback |
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7 | Private networks |
169.254.0.0/16, fe80::/10 | Link-local, includes 169.254.169.254 metadata (AWS, GCP, Azure, others) |
100.64.0.0/10 | Carrier-grade NAT, used inside some clouds and Kubernetes |
0.0.0.0/8, :: | “This host”; 0.0.0.0 reaches localhost on Linux |
::ffff:0:0/96 | IPv4-mapped IPv6: unwrap and recheck the IPv4 address |
64:ff9b::/96 | NAT64: unwrap and recheck |
fd00:ec2::254 | AWS IMDS over IPv6 (inside fc00::/7) |
| Your VPC / pod / service CIDRs | Especially if they are public ranges reused internally |
Validate at connect time
Checking the hostname, then letting the HTTP client resolve it again, is broken by DNS rebinding (first lookup public, second private). Resolve once, check every address, and connect to the checked IP, or hook the dialer so the check runs on the socket address.
Go (the cleanest pattern: a dialer Control hook sees the final IP):
func safeControl(network, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil { return err }
ip, err := netip.ParseAddr(host)
if err != nil { return err }
ip = ip.Unmap()
if !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
cgnat.Contains(ip) || nat64.Contains(ip) {
return fmt.Errorf("blocked destination %s", ip)
}
return nil
}
var cgnat = netip.MustParsePrefix("100.64.0.0/10")
var nat64 = netip.MustParsePrefix("64:ff9b::/96")
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
Proxy: nil, // or your egress proxy; never ProxyFromEnvironment by accident
DialContext: (&net.Dialer{Timeout: 5 * time.Second, Control: safeControl}).DialContext,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 { return errors.New("too many redirects") }
if req.URL.Scheme != "https" { return errors.New("scheme not allowed") }
return nil // the dialer re-checks the new host's IP
},
}
Python: resolve, check with ipaddress, then connect to the IP while sending the
original Host header and SNI. This is fiddly with requests; prefer a maintained
transport such as advocate (unmaintained forks exist: check activity) or put the check
in an egress proxy. The check itself:
import ipaddress, socket
def is_public(ip_str: str) -> bool:
ip = ipaddress.ip_address(ip_str)
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
ip = ip.ipv4_mapped
return ip.is_global and not ip.is_multicast
def resolve_public(host: str, port: int) -> list[str]:
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
ips = {i[4][0] for i in infos}
if not ips or not all(is_public(ip) for ip in ips):
raise ValueError("destination not allowed")
return sorted(ips)
Node: pass a custom lookup to the http.Agent that rejects non-public addresses, or
use a maintained package such as ssrf-req-filter with axios/got; disable automatic
redirects (redirect: "manual" in fetch) and re-validate each hop.
URL parsing rules
- Parse once with one library and use the parsed object for both checking and fetching.
Parser differentials (
http://a@b, backslashes,%2e, Unicode dots) cause bypasses. - Allow only
https(andhttpif needed). Rejectfile,gopher,ftp,dict,ldap,jar,data. - Reject userinfo (
user:pass@) in user-supplied URLs. - Do not “normalise” alternate IP forms yourself: let the resolver turn
2130706433,0x7f.1,127.1and017700000001into an IP, then check the IP.
Egress proxy
For anything that fetches arbitrary URLs at scale, route it through a dedicated egress proxy that enforces the IP policy centrally (Stripe’s Smokescreen is a known open source option) and give the fetching service no other route out. This also catches libraries you did not know made requests.
Cloud metadata hardening
- AWS: require IMDSv2 (
HttpTokens=required) and setHttpPutResponseHopLimit=1on instances; containers then cannot reach IMDS through the host. Changing instance metadata options affects running workloads: confirm with the user before applying. - Kubernetes: block
169.254.169.254with NetworkPolicy for pods that do not need it; use workload identity (IRSA / EKS Pod Identity, GKE Workload Identity) instead of node credentials. - Least-privilege instance roles shrink the damage when SSRF still happens.
Verification
Write tests for the fetch function with a fake resolver or a local server:
- [ ] http://127.0.0.1/, http://localhost/, http://[::1]/ -> blocked
- [ ] http://169.254.169.254/latest/meta-data/ -> blocked
- [ ] http://2130706433/, http://0x7f000001/, http://127.1/ -> blocked
- [ ] http://[::ffff:127.0.0.1]/, http://0.0.0.0/ -> blocked
- [ ] public host that 302-redirects to http://127.0.0.1/ -> blocked
- [ ] hostname resolving to a private IP (hosts file or test DNS) -> blocked
- [ ] file:///etc/passwd, gopher://... -> blocked
- [ ] https://example.com/ -> allowed
Run payload tests against local or staging builds the user owns. Never probe third-party infrastructure.
Output template
## SSRF review: <scope>
| Fetcher | File:line | URL source | Control before | Control after |
|---|---|---|---|---|
| Webhook delivery | jobs/webhook.go:40 | user setting | none | dialer IP check + redirect limit |
Cloud metadata: IMDSv2 <required?> hop limit <n>
Residual risk: <renderers, parsers, internal public-IP ranges>
Gotchas
- Error messages and timing reveal internal network layout (blind SSRF port scanning); return one generic error and cap timeouts.
- Webhook “test” buttons are often a separate code path without the check.
HTTP_PROXY/HTTPS_PROXYenvironment variables can silently bypass your dialer check or route through an internal proxy; set the transport’s proxy explicitly.- Blocking only
169.254.169.254missesfd00:ec2::254,metadata.google.internal(which resolves to the link-local IP) and alternate encodings.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "SSRF Prevention"
short_description: "Check the IP you connect to, not the URL"
default_prompt: "$ssrf-prevention Our webhook feature lets users enter any URL — make it SSRF-safe" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.