Skip to main content

ssrfguard

SSRF protection that connects to the address it validated.

Every other SSRF guard in Python validates a hostname and then hands the URL to an HTTP client that resolves DNS a second time. The attacker moves the record in between. This one resolves once, validates every answer, and connects to that address, never to a name.

PyPI Python Runtime dependencies License

pip install "ssrfguard[httpx]"      # or ssrfguard[requests], or both

Status says what is and is not done, and CHANGELOG.md says what has moved. Examples has the two commands for running the nine examples from a clone.

from ssrfguard import Policy
from ssrfguard.httpx import Client

with Client(policy=Policy()) as client:
    response = client.get(untrusted_url)

That is the whole of it. There is nothing to remember to call: no validate_url_first(), no decorator, no middleware ordering. Client is an httpx.Client, so verbs, headers, timeouts, streaming and pooling all behave the way they already do, and the check happens at the seam where a socket is opened. Redirects, retries and pool refills go through it whether or not anyone thought about them.

ssrfguard.httpx.AsyncClient and ssrfguard.requests.Session are the same guarantee for the async client and for requests.

Jump to: The problem  ·  What you get  ·  Documentation  ·  Examples  ·  What it costs  ·  Status

The problem

An SSRF guard is three lines long and the third one is the vulnerability.

address = socket.gethostbyname(urlparse(url).hostname)   # lookup 1
if is_private(address):                                  # validated ...
    raise Forbidden
return httpx.get(url)                                    # ... and discarded

The third line resolves the name again. Whatever the second line approved is not what the third line connects to, and the gap between them is where the record moves.

2026 alone produced this bug in datamodel-code-generator (CVE-2026-55391), mcp-atlassian (CVE-2026-27826), crewAI (CVE-2026-62240), mlflow, AutoGPT, Craft CMS and pydantic-ai. The advisories describe it in their own words. mcp-atlassian: "the guard validates an IP it then discards; the connection re-resolves an unpinned hostname."

Same bug, seven times, in one year, in libraries written by people who knew what SSRF was. It keeps happening because a validator that takes a URL and returns a URL is the most natural API in the world and is structurally incapable of being correct.

The fix

check_url(url) -> Target          no I/O; scheme, port, credentials, host shape, literal address
resolve(target) -> Address[]      exactly one lookup, every answer checked against the policy
connect(addresses) -> socket      no name in scope, so nothing to re-resolve

connect cannot resolve anything, because it is not given anything to resolve. That is a property of the signature rather than a promise in a docstring, and it is the whole argument.

The pinning lives at the client's connection seam rather than in a wrapper around get(), so the certificate is still verified against the hostname. Pinning that reached TLS as an IP would silently disable hostname verification and trade an SSRF hole for a worse one; the suite reads the SNI off the wire to prove it does not.

What you get

One lookup per connection, and the socket lands on it Proved against a real nameserver on a real UDP socket that moves its record mid-request, not against a Python stub that cannot
Three drop-in clients httpx, httpx async and requests. Ordinary subclasses, so redirects, retries and pooling all pass through the same seam
Refusals you can act on Every message names the value and the rule that refused it. Whole messages are pinned by tests, because a refusal nobody can act on gets configured around
An address table generated from IANA 60 rows, refreshed by a script and re-fetched in CI. Wrappers such as ::ffff:169.254.169.254 and 64:ff9b::a9fe:a9fe are decoded rather than answered about
Encoded hosts refused twice 0177.0.0.1, 2130706433, 127.1, circled digits. Refused at the URL layer, and again at resolution wherever the platform's resolver decodes them
Redirects counted by the policy Not by the client, whose limit exists to stop loops. Every hop re-checked; credentials dropped when the origin changes
A proxy is refused, not silently bypassed A proxy resolves the target itself, so pinning cannot reach it. Saying so beats leaving you believing in a control that stopped running
Zero runtime dependencies Enforced by a test against the built metadata, and by a lane that installs the wheel alone into a clean interpreter

Documentation

Start with Getting started. After that the guides are shaped by task rather than by module.

Guide What is in it
Getting started Install, your first request, what the default policy does, what a refusal looks like
Configuring a policy Every field and its default, reaching your own internal services, partial answers, redirects, proxies
The clients The three surfaces, why they are clients and not transports, TLS, the async resolver pool, and the three named asymmetries
Errors The hierarchy, what each carries, how to handle them, and what is deliberately not wrapped
Using the pieces directly check_url / resolve / connect for a protocol this package ships no client for, including the correct TLS wrap
The address table What is in it, how a wrapper is decoded, building your own, and every place it departs from IANA
Resolvers getaddrinfo has no timeout; a resolver that does, what it does not know in exchange, and why half an answer is refused
Watching what it decides Reporting every permit and refusal to an observer, the four stages, and why a broken sink cannot fail a request
What it costs The measured numbers, the shape that matters more, and what is not bounded
Why this exists The bug, the fix, and the failures this prevents with the test that proves each one
Threat model What an attacker controls, what meets each capability, and the residual risk stated in detail

Examples

examples/ is the other half of the documentation, and it is executed rather than described: nine runnable files, each of which works with no arguments, no network and no fixtures, and every one is run by the test suite.

git clone https://github.com/kmoneil/ssrfguard && cd ssrfguard
uv sync --frozen --all-extras
.venv/bin/python examples/03_the_pin.py

03_the_pin.py is the one to run first. It stands up a nameserver that answers honestly once and then moves the record to the metadata endpoint, and shows the three things that happen:

1. Validated, then connected, with the record moving in between
  check_url  -> <Target http host=inside.example port=42165>
  resolve    -> ['127.0.0.1:42165 (via inside.example)']   (lookups: 1)
  ... the nameserver now answers 169.254.169.254 for that name ...
  connect    -> peer 127.0.0.1   (lookups: 1)

2. The same seam, inside a client, over three requests
  request 1: 200 'ok'   (lookups: 1)
  request 2: 200 'ok'   (lookups: 1)
             the pool reused the connection, so nothing was resolved
  request 3: BlockedAddressError: 169.254.169.254 is not permitted: 169.254.169.254/32 is
             Cloud metadata (AWS, GCP, Azure IMDS) (RFC3927)

The rest cover refusal messages, policy recipes, the async client, requests, redirects, the building blocks and the address table. examples/README.md is the index.

What it costs

The URL check runs once per request. Resolution and the address check run once per connection.

So the per-request cost is one check_url, and everything expensive is amortised over a connection's lifetime.

Measured Per call
check_url, ordinary hostname 4.2 us
check_url, literal IPv4 9.0 us
check_url, internationalised name 20.9 us
check_url, the most expensive URL a default policy accepts 560 us
import ssrfguard, over an empty interpreter 19 ms

Python 3.13 on aarch64, CPU time on the calling thread. httpx spends roughly 170 microseconds of its own CPU on a request over loopback, so an ordinary check is a couple of percent of that and nothing measurable against a request that crosses a network. python scripts/lanes.py cost prints the numbers for your hardware.

None of these is a promise. What is enforced is in tests/test_cost.py, and none of it is a stopwatch: two of the three assertions compare one measurement to another taken in the same run, and the third counts calls and holds no clock at all. What it costs has the three surprises worth knowing about before you meet them.

Requirements

Python 3.10 or newer, and nothing else.

The floor is 3.10 because that is the lowest interpreter this project can fully verify: below it, requests and urllib3 cap at releases older than the connection seam was measured against, and mypy refuses to type-check the floor at all. Ubuntu 22.04 LTS ships 3.10 and is supported into 2027.

Why zero dependencies

A capable library with a dependency tree is a procurement problem. A capable library without one is a single approval.

pip install ssrfguard installs exactly one thing. The adapters live behind extras (ssrfguard[httpx], ssrfguard[requests]) and import their client lazily, so importing the package never touches third-party code. This is checked two ways: tests/test_zero_deps.py reads the built metadata, and the zero-deps lane installs the wheel alone into a clean interpreter and fails if importing it loads anything that is not ours.

The SBOM attached to every release is nearly empty. That is the point.

What this is not

This is not a replacement for network egress control. A library cannot stop a compromised process from opening a socket, and claiming otherwise is how teams end up with one control where they needed two. Run both; this is the cheap one, and it fails closed with a message naming what it refused, which is what makes it useful in the case that is a bug rather than an intruder.

It also does not inspect application-layer traffic once a permitted host is reached, does not bound DNS resolution time on the synchronous path, and does not guard a request made by anything that is not one of its clients. SECURITY.md has the scope in full.

An address wrongly refused is a bug too. A guard with false positives gets removed, and a removed control protects nothing. Both directions are worth reporting.

Status

0.2.0. The address table, the policy layer, resolution, the connection layer and all three client surfaces are built. The central claim is demonstrated rather than argued: a DNS server on loopback moves a record between the validation call and the connect call, and the connection lands on the address that was validated.

0.2.0 adds two things and takes nothing away, both opt-in, both leaving the defaults where they were. UdpResolver gives a lookup a deadline, which getaddrinfo cannot be given, and closes the denial-of-service surface SECURITY.md used to document as simply known. An observer makes every permit and refusal something a caller can see, where before a decision survived only by being raised and only if nobody caught it.

There is still no maturity label, and that is a decision rather than a deferral. The package carried 3 - Alpha while the rebinding proof was missing, which was the one claim worth withholding; the proof exists, so keeping it would have asserted a maturity rather than withheld one. Promoting it to 4 - Beta would swap one unearned claim for another. What is true instead is measurable and is on this page: 1001 tests, 100% branch coverage, ten gating lanes, a mutation register of 156 survivors the suite is held against, and no independent audit. A reader can weigh those. A one-word classifier only asks them to take our word for something.

0.2.0 rather than 1.0.0, because the API may still move. Nothing here is known to be wrong; the number is about what we are willing to promise not to change. CHANGELOG.md has what has moved.

How this was built

This library was built with AI assistance. This work is a collaboration between human writing and AI generation. It was directed, reviewed, and accepted by a human author who takes full responsibility for the final result.

Humans and models produce slop in roughly equal measure. What decides whether software is good is the verification: what is actually tested, what is measured against a real system instead of recalled, and which claims something would catch if they stopped being true. The failure mode worth designing against is confident plausibility, and Why this exists says what is done about it here.

Contributing

CONTRIBUTING.md has the setup, how to run the lanes, and the handful of things a review will send back. Two lines to start:

uv sync --frozen --all-extras
python scripts/lanes.py            # every lane, what it checks, whether it gates

Found a vulnerability? SECURITY.md, not an issue.

License

Apache-2.0.

Download files

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

Source Distribution

ssrfguard-0.2.0.tar.gz (340.3 kB view details)

Uploaded Source

Built Distribution

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

ssrfguard-0.2.0-py3-none-any.whl (86.5 kB view details)

Uploaded Python 3

File details

Details for the file ssrfguard-0.2.0.tar.gz.

File metadata

  • Download URL: ssrfguard-0.2.0.tar.gz
  • Upload date:
  • Size: 340.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for ssrfguard-0.2.0.tar.gz
Algorithm Hash digest
SHA256 d48a4965775d6014d09d903c65840de81bbb77c7e6556009c61ddb7b23effc70
MD5 fcde4d3e144250709b47f0ce15c52c31
BLAKE2b-256 5428fe34963ff737fdff3dc8777c4c14dea5d2e9c6a0e26ac14d5f0a3613a438

See more details on using hashes here.

Provenance

The following attestation bundles were made for ssrfguard-0.2.0.tar.gz:

Publisher: release.yml on kmoneil/ssrfguard

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

File details

Details for the file ssrfguard-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: ssrfguard-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 86.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for ssrfguard-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 72cf74b5edbeb3a24cc1b16c080fda281d2ed8afc260b0bf5e48a64d4598559a
MD5 53314df94be428f274398d3c68956227
BLAKE2b-256 5b0222bf79e17e9319b1bd5234afc89e792cb80243c70dc8242de9c483b22819

See more details on using hashes here.

Provenance

The following attestation bundles were made for ssrfguard-0.2.0-py3-none-any.whl:

Publisher: release.yml on kmoneil/ssrfguard

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

Release history Release notifications | RSS feed

0.3.0

2 files

This release

0.2.0 This release

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page