Metadata-Version: 2.5
Name: pantheon-ssrf-guard
Version: 0.3.0
Summary: A pinned-IP SSRF egress guard for Python's stdlib HTTP client. A DNS rebind cannot complete a handshake with an internal address.
Project-URL: Homepage, https://github.com/Igfray/pantheon-ssrf-guard
Author: Isaac Teague Frayling
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: dns-rebinding,egress,http,security,ssrf,urllib
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Security
Requires-Python: >=3.10
Description-Content-Type: text/markdown

# pantheon-ssrf-guard

[![tests](https://github.com/Igfray/pantheon-ssrf-guard/actions/workflows/ci.yml/badge.svg)](https://github.com/Igfray/pantheon-ssrf-guard/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/pantheon-ssrf-guard)](https://pypi.org/project/pantheon-ssrf-guard/)
[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://pypi.org/project/pantheon-ssrf-guard/)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)

A small, dependency-free **SSRF egress guard** for Python's standard-library HTTP client — one that **survives DNS rebinding**.

> The guarantee: an outbound request cannot reach a private, loopback, link-local, cloud-metadata, or CGN address — **even if the hostname resolves public at check time and rebinds to an internal IP before the socket connects.**

Extracted from [PANTHEON](https://pantheonlabs.co.uk), a multi-tenant AI substrate, where it is the single canonical guard on every outbound path (link imports, uploaded-URL fetches, external MCP-server transports).

## Why a pre-check isn't enough

The common "block internal hosts" approach resolves the hostname and rejects private IPs *before* connecting. An attacker defeats it with **DNS rebinding**: the name resolves to a public IP when you check it, then to `127.0.0.1` / `169.254.169.254` (cloud metadata) / an internal service by the time the socket actually opens. A check *after* `connect()` is too late for the handshake: the TCP and TLS handshakes have already reached whatever address the second lookup returned.

`pantheon-ssrf-guard` does not look the name up twice:

1. **`pin_public_address(host)`** — resolve once. If any address is non-public, raise before a socket exists.
2. **`GuardedHTTP(S)Connection`** — open the socket to that IP literal, never to the hostname. TLS starts only after the connected peer is that IP. SNI and the certificate check stay on the original hostname.

The classifier's catch-all is `not ip.is_global`, so it also rejects everything the obvious flags miss — RFC 6598 shared/CGN space (`100.64.0.0/10`, used by Kubernetes/internal load balancers and the Alibaba/Tencent/Oracle metadata endpoint `100.100.100.200`), plus benchmark, documentation, and NAT64 ranges.

## Install

```bash
pip install pantheon-ssrf-guard   # or: copy the single ssrf_guard.py file into your project
```

## Use

```python
from ssrf_guard import guarded_opener, SsrfBlocked

opener = guarded_opener()          # https-capable; redirects refused by default (each hop is pinned if allowed)

try:
    with opener.open("https://example.com/data.json", timeout=10) as resp:
        body = resp.read()
except SsrfBlocked:
    ...   # the target resolved (or rebound) to a non-public address — refused before any data was sent
```

Lower-level pieces are exported too if you're building your own client:

```python
from ssrf_guard import host_is_public, ip_is_public, peer_is_public
from ssrf_guard import GuardedHTTPHandler, GuardedHTTPSHandler
```

## What it does *not* do

- It doesn't proxy, cache, or rate-limit — it only refuses non-public destinations.
- It guards `urllib`/`http.client`. For `httpx`/`requests` you'd apply the same idea at their transport layer (the classification functions here are reusable).
- It is not a WAF; it's the one specific control that stops server-side request forgery to internal infrastructure.
- A name is resolved once. If any address is non-public, nothing is attempted. The socket is opened to the IP from that resolution, not to the name, so a second lookup cannot rebind it. TLS starts only after the connected peer is that IP. The certificate is still checked against the original hostname. SNI is that hostname, not the IP.
- This does not cover a proxy. A tunnel hides the destination, so a proxied connection is refused. `trust_env_proxy=True` does not override that.
- This does not cover a network that delivers a public IP to a different machine. That is not DNS.


## Changelog

- **0.3.0** — connect to a pinned public IP. A DNS rebind no longer gets a TCP or TLS handshake. Proxied connections are refused, because the pin cannot see past a tunnel.
- **0.2.0** — **scheme allowlist at the opener boundary** and **environment proxies off by default**, after an external review reproduced two bypasses: `build_opener()` also installs `file:`/`ftp:`/`data:` handlers, so `opener.open("file:///etc/passwd")` read a local file without the HTTP guard ever running; and a `ProxyHandler` inherited from `HTTP(S)_PROXY` moves the connection to the proxy, so the peer check validated the *proxy* while the real destination went unexamined. Now only `http`/`https` are reachable (enforced per-hop, so a redirect to `ftp:` is refused too), the dangerous handlers are removed from the opener entirely, and `ProxyHandler({})` is installed unless the caller passes `trust_env_proxy=True`. The handshake residue documented in 0.2.x is closed in 0.3.0.

- **0.1.1** — **the pre-check is now wired into the opener** (so "two-layer" is true, not aspirational): a `GuardedHTTP(S)Connection` rejects a non-public host by name BEFORE opening the socket, then re-checks the actual peer at connect time (the rebind guard). And **IPv4-mapped (`::ffff:…`) + NAT64 (`64:ff9b::/96`) addresses are normalised to their embedded v4 before classifying**, so an internal target smuggled inside a v6 address is blocked *version-independently* instead of trusting whichever `is_*` flags a given CPython sets.
- **0.1.0** — initial release.

## License

Apache-2.0. See `LICENSE`.
