SSRF-hardened outbound HTTP: resolve-once + pin the IP, block internal addresses, refuse redirects
Project description
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).httpxis 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 to169.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
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:
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):
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 / …):
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.
# 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_urlwhen it is stored does not protect the request when it is sent — the domain can rebind to an internal address in between.safe-outboundis 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_bytespin the socket to the validated IP.safe_httpx_clientre-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 throughsafe_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=Falseis 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.
Acknowledgements
Extracted from the RPG Roleplay platform (https://github.com/felixchaos/rpg-roleplay-platform).
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 safe_outbound-0.1.0.tar.gz.
File metadata
- Download URL: safe_outbound-0.1.0.tar.gz
- Upload date:
- Size: 21.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
63ce77c62417c91c9c982c9a21c8ff19cabdff974721eea7f91aa23caff232cc
|
|
| MD5 |
4425ba99d9344285fa57aed9d0b28c64
|
|
| BLAKE2b-256 |
1822ad3228f79988c9fc9a94afb33088393ac20945e729eb8341d7acd0eb7e3e
|
Provenance
The following attestation bundles were made for safe_outbound-0.1.0.tar.gz:
Publisher:
release.yml on felixchaos/safe-outbound
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
safe_outbound-0.1.0.tar.gz -
Subject digest:
63ce77c62417c91c9c982c9a21c8ff19cabdff974721eea7f91aa23caff232cc - Sigstore transparency entry: 2168025532
- Sigstore integration time:
-
Permalink:
felixchaos/safe-outbound@f9131b1e9b5f811c0d0c8773a8a773abb1c4d6b4 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/felixchaos
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f9131b1e9b5f811c0d0c8773a8a773abb1c4d6b4 -
Trigger Event:
push
-
Statement type:
File details
Details for the file safe_outbound-0.1.0-py3-none-any.whl.
File metadata
- Download URL: safe_outbound-0.1.0-py3-none-any.whl
- Upload date:
- Size: 15.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cd15740bdf6788ef95c76be71b6658f0a457fdc7574c64d532a4e28b766c2b85
|
|
| MD5 |
f1aded4cb9034263074a3162adcf352c
|
|
| BLAKE2b-256 |
e4c0fd95ba1f8599fcee99d464acc8de4574aa4369e086ffc36613ec64aa4e66
|
Provenance
The following attestation bundles were made for safe_outbound-0.1.0-py3-none-any.whl:
Publisher:
release.yml on felixchaos/safe-outbound
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
safe_outbound-0.1.0-py3-none-any.whl -
Subject digest:
cd15740bdf6788ef95c76be71b6658f0a457fdc7574c64d532a4e28b766c2b85 - Sigstore transparency entry: 2168025558
- Sigstore integration time:
-
Permalink:
felixchaos/safe-outbound@f9131b1e9b5f811c0d0c8773a8a773abb1c4d6b4 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/felixchaos
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f9131b1e9b5f811c0d0c8773a8a773abb1c4d6b4 -
Trigger Event:
push
-
Statement type: