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).
Metadata
Release files for safe-outbound 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| safe_outbound-0.1.0.tar.gz | 21.4 kB | With provenance | Details for safe_outbound-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| safe_outbound-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for safe_outbound-0.1.0-py3-none-any.whl |
Total release size: 37.2 kB
Release files / safe_outbound-0.1.0.tar.gz
| Download URL | safe_outbound-0.1.0.tar.gz |
|---|---|
| Size | 21.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
63ce77c62417c91c9c982c9a21c8ff19cabdff974721eea7f91aa23caff232cc
|
|
BLAKE2b-256 checksum How to use checksums |
1822ad3228f79988c9fc9a94afb33088393ac20945e729eb8341d7acd0eb7e3e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 14, 2026.
Transparency logRelease files / safe_outbound-0.1.0-py3-none-any.whl
| Download URL | safe_outbound-0.1.0-py3-none-any.whl |
|---|---|
| Size | 15.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
cd15740bdf6788ef95c76be71b6658f0a457fdc7574c64d532a4e28b766c2b85
|
|
BLAKE2b-256 checksum How to use checksums |
e4c0fd95ba1f8599fcee99d464acc8de4574aa4369e086ffc36613ec64aa4e66
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 14, 2026.
Transparency logSecurity for safe-outbound 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for safe_outbound-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 14, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.