Skip to main content

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

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

Acknowledgements

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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

safe_outbound-0.1.0.tar.gz (21.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

safe_outbound-0.1.0-py3-none-any.whl (15.8 kB view details)

Uploaded Python 3

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

Hashes for safe_outbound-0.1.0.tar.gz
Algorithm Hash digest
SHA256 63ce77c62417c91c9c982c9a21c8ff19cabdff974721eea7f91aa23caff232cc
MD5 4425ba99d9344285fa57aed9d0b28c64
BLAKE2b-256 1822ad3228f79988c9fc9a94afb33088393ac20945e729eb8341d7acd0eb7e3e

See more details on using hashes here.

Provenance

The following attestation bundles were made for safe_outbound-0.1.0.tar.gz:

Publisher: release.yml on felixchaos/safe-outbound

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for safe_outbound-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 cd15740bdf6788ef95c76be71b6658f0a457fdc7574c64d532a4e28b766c2b85
MD5 f1aded4cb9034263074a3162adcf352c
BLAKE2b-256 e4c0fd95ba1f8599fcee99d464acc8de4574aa4369e086ffc36613ec64aa4e66

See more details on using hashes here.

Provenance

The following attestation bundles were made for safe_outbound-0.1.0-py3-none-any.whl:

Publisher: release.yml on felixchaos/safe-outbound

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page