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.

  • Security
  • $ssrf-prevention
  • 186 lines
  • 2 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- ssrf-prevention

Installs to ~/.agents/skills/ssrf-prevention/ for every project on this machine.

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

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.

~/.agents/skills/ssrf-prevention/SKILL.md

--- name: ssrf-prevention description: Defends features that fetch user-supplied URLs (webhooks, link previews, image import, PDF rendering, OAuth/OIDC discovery) against server-side… license: MIT ---

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

FeatureRecommended 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 contentRun 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 instanceIMDSv2 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:

RangeWhy
127.0.0.0/8, ::1Loopback
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7Private networks
169.254.0.0/16, fe80::/10Link-local, includes 169.254.169.254 metadata (AWS, GCP, Azure, others)
100.64.0.0/10Carrier-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/96IPv4-mapped IPv6: unwrap and recheck the IPv4 address
64:ff9b::/96NAT64: unwrap and recheck
fd00:ec2::254AWS IMDS over IPv6 (inside fc00::/7)
Your VPC / pod / service CIDRsEspecially 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 (and http if needed). Reject file, 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.1 and 017700000001 into 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 set HttpPutResponseHopLimit=1 on 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.254 with 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_PROXY environment variables can silently bypass your dialer check or route through an internal proxy; set the transport’s proxy explicitly.
  • Blocking only 169.254.169.254 misses fd00: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.yamlgenerated from this page

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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem