Metadata-Version: 2.4
Name: safe-outbound
Version: 0.1.0
Summary: SSRF-hardened outbound HTTP: resolve-once + pin the IP, block internal addresses, refuse redirects
Project-URL: Homepage, https://github.com/felixchaos/safe-outbound
Project-URL: Issues, https://github.com/felixchaos/safe-outbound/issues
Author: Stellatrix Labs
License-Expression: MIT
License-File: LICENSE
Keywords: dns-rebinding,http,httpx,outbound,security,ssrf,urllib
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
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: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.10
Provides-Extra: httpx
Requires-Dist: httpx; extra == 'httpx'
Description-Content-Type: text/markdown

# safe-outbound

An SSRF-hardened outbound HTTP layer for Python. When a `base_url`, webhook, or
download URL is attacker-influenced, `safe-outbound` makes the request *fail
closed* against internal targets: it resolves DNS once, pins the socket to the
validated IP, blocks private/reserved addresses, and refuses redirects.

- **Zero dependencies for the core.** Pure standard library (`urllib`, `http.client`, `socket`, `ipaddress`). `httpx` is an optional extra.
- **Resolve once, pin the IP.** The exact address that passed validation is the exact address dialed — DNS-rebinding TOCTOU has nothing to grab.
- **Every DNS record must be public.** If *any* A/AAAA record is internal, the whole request is refused (no "validate one record, connect to another").
- **No redirects.** A 30x that would bounce an `Authorization`-bearing request to `169.254.169.254` (cloud metadata) or an internal host is refused, not followed.
- **http/https only.** `file://`, `ftp://`, `gopher://`, `data://` and friends are rejected before any I/O.
- **TLS stays honest.** After pinning to an IP, SNI and certificate validation still use the original hostname.

## Install

```bash
pip install safe-outbound              # core (urllib)
pip install safe-outbound[httpx]       # + the httpx client wrapper
```

Requires Python 3.10+.

## Quickstart

**Open an attacker-influenced URL safely:**

```python
from safe_outbound import safe_urlopen, OutboundBlocked

try:
    with safe_urlopen("https://api.some-relay.example/v1/models") as resp:
        body = resp.read()
except OutboundBlocked as exc:
    # Target resolved to a private/local/reserved address, or used a bad scheme.
    ...
```

**Download bytes from a provider-supplied URL (e.g. a generated image):**

```python
from safe_outbound import safe_get_bytes

# Follows up to 3 redirects, re-validating every hop; caps the body at 25 MB.
data = safe_get_bytes("https://cdn.example.com/generated/image.png")
```

**Wrap an SDK that must use httpx (OpenAI / Anthropic / …):**

```python
from safe_outbound import safe_httpx_client, outbound_headers
from openai import OpenAI

client = OpenAI(
    base_url=user_supplied_base_url,       # attacker-influenced
    api_key=key,
    http_client=safe_httpx_client(),        # follow_redirects=False + per-request IP guard
    default_headers=outbound_headers(),     # WAF-friendly User-Agent
)
```

## API

| Function | Signature | Description |
| --- | --- | --- |
| `safe_urlopen` | `safe_urlopen(req, *, enforce=True, allow_private=False, timeout=..., user_agent=None)` | Open a `urllib` Request or URL string with the full guard. Returns the `urllib` response. |
| `safe_get_bytes` | `safe_get_bytes(url, *, enforce=True, allow_private=False, timeout=60.0, max_bytes=25*1024*1024, max_redirects=3, user_agent=None) -> bytes` | GET a URL's bytes; follows redirects manually, re-validating each hop; caps the body size. |
| `safe_httpx_client` | `safe_httpx_client(*, enforce=True, allow_private=False, timeout=30.0, proxy=None, http2=True, user_agent=None) -> httpx.Client` | An httpx client that never follows redirects and re-validates the target on every request. Needs the `httpx` extra. |
| `ip_is_internal` | `ip_is_internal(ip_str) -> bool` | `True` for private/local/reserved/unroutable literal IPs (fail-closed on non-IP input). |
| `resolve_pinned_ip` | `resolve_pinned_ip(host, port) -> str` | Resolve a host and return one validated public IP; raises if any record is internal. |
| `outbound_user_agent` | `outbound_user_agent() -> str` | The default outbound User-Agent (`$SAFE_OUTBOUND_UA` overrides). |
| `outbound_headers` | `outbound_headers() -> dict[str, str]` | `{"User-Agent": outbound_user_agent()}` — handy as SDK `default_headers`. |
| `OutboundBlocked` | `class OutboundBlocked(ValueError)` | Raised when a request is refused for SSRF reasons. |

## `enforce` and `allow_private`

Two structural defenses are **always on** and cannot be disabled: the
http/https scheme allowlist and refusal to follow redirects. The IP guard
(re-resolve + block internal addresses + pin the socket) is gated by two flags:

| `enforce` | `allow_private` | IP block + pin | Refuse redirects | http/https only | Use case |
| --- | --- | --- | --- | --- | --- |
| `True` (default) | `False` (default) | **yes** | yes | yes | Hosted / multi-tenant — full SSRF hardening |
| `True` | `True` | no | yes | yes | Local / self-hosted — allow `127.0.0.1`, fake-ip proxy |
| `False` | (any) | no | yes | yes | You have your own network egress controls |

### Local / self-hosted deployments: `allow_private=True`

On a single-user, self-hosted deployment the operator *is* the user, and internal
targets are legitimate:

- a local model server (Ollama / LM Studio on `127.0.0.1`), or
- a proxy that maps public API domains onto a fake-ip range (e.g. Clash uses
  `198.18.0.0/15`).

In hosted mode these resolve to "internal" and are blocked. Pass
`allow_private=True` (or `enforce=False`) to permit them. Redirect refusal and
the scheme allowlist still apply, so you don't lose the structural defenses.

```python
# Point at a local Ollama without tripping the internal-address block.
with safe_urlopen("http://127.0.0.1:11434/api/tags", allow_private=True) as resp:
    ...
```

Decide this from your own configuration (e.g. "is this a hosted deployment?"),
not from user input.

## What counts as internal

`ip_is_internal` blocks these, on **every** supported Python version (the ranges
are pinned explicitly, because `ipaddress`'s own `is_private`/`is_reserved`
classification has shifted between 3.10 and 3.14):

- **IPv4:** `0.0.0.0/8`, `10/8`, `100.64/10` (CGNAT), `127/8`, `169.254/16`
  (link-local incl. cloud metadata), `172.16/12`, `192.0.0/24`, `192.0.2/24`,
  `192.168/16`, `198.18/15` (benchmarking / fake-ip), `198.51.100/24`,
  `203.0.113/24`, `224/4` (multicast), `240/4` (reserved).
- **IPv6:** `::/128`, `::1/128`, `64:ff9b::/96` (NAT64), `100::/64`, `2001::/32`
  (Teredo), `2001:db8::/32`, `2002::/16` (6to4), `fc00::/7` (ULA), `fe80::/10`
  (link-local), `fec0::/10`, `ff00::/8` (multicast).
- **Embedded IPv4:** IPv4-mapped (`::ffff:a.b.c.d`), NAT64 and 6to4 addresses are
  unwrapped and their inner IPv4 is checked too, so a private IPv4 cannot hide
  inside an IPv6 wrapper.

Numeric IP obfuscation (decimal `2130706433`, hex `0x7f000001`, …) is handled
for free: whatever `getaddrinfo` normalizes the literal to is the exact address
that is both validated and dialed.

## Security notes and limits

- **Write-time validation is not enough.** Validating a `base_url` when it is
  *stored* does not protect the request when it is *sent* — the domain can
  rebind to an internal address in between. `safe-outbound` is a *use-time*
  defense; keep any write-time validation you have as defense-in-depth.
- **The urllib layer pins; the httpx layer validates.** `safe_urlopen` /
  `safe_get_bytes` pin the socket to the validated IP. `safe_httpx_client`
  re-resolves and refuses internal targets on every request but does **not** pin
  the socket (httpx/httpcore has no clean hook for it without weakening TLS
  verification), so a small DNS-rebinding window remains. For the strongest
  guarantee, route through `safe_urlopen`.
- **Proxies bypass the IP block.** A proxy can legitimately point at an internal
  address, so `safe_httpx_client(proxy=...)` should only be used in local mode.
  A hosted, multi-tenant backend must never forward a user-supplied proxy.
- **`enforce=False` is an escape hatch, not a default.** It disables the IP
  block and pinning (redirect refusal and the scheme allowlist remain). Reserve
  it for callers with their own egress firewall.

## User-Agent

Requests carry a browser User-Agent by default, because many self-hosted
OpenAI-compatible relays sit behind a WAF that blocks known SDK / scraper
signatures (`OpenAI/Python …`, `Python-urllib/…`). Override it globally with the
`SAFE_OUTBOUND_UA` environment variable, per call with `user_agent=`, or by
setting your own `User-Agent` header on the request (which is always respected).

## License

MIT — see [LICENSE](LICENSE).

## Acknowledgements

Extracted from the RPG Roleplay platform (https://github.com/felixchaos/rpg-roleplay-platform).
