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

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.1.0, the first release. 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.

There is still no maturity label, and that is now 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: 758 tests, 100% branch coverage, ten gating lanes, a mutation register 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.1.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.1.0.tar.gz (279.7 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.1.0-py3-none-any.whl (67.4 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for ssrfguard-0.1.0.tar.gz
Algorithm Hash digest
SHA256 86b02fce5a4a10b39e9ffa49d15e2f41dff2b6a6b06736301a2d5195413f3a11
MD5 77ffa93c740d1fb1f1c877a8f26f4d3a
BLAKE2b-256 0402115ac4c85161b477919ad3dd9d4150841a12865b9b551722728b85d7be6d

See more details on using hashes here.

Provenance

The following attestation bundles were made for ssrfguard-0.1.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.1.0-py3-none-any.whl.

File metadata

  • Download URL: ssrfguard-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 67.4 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.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 246de33b0a4f7bee3f49dcb6818f71f77f5c3a12f56d4389a5d89030343fa451
MD5 a33e1c2e4a94ce7c1988276f55b0784f
BLAKE2b-256 b67f14cd00e35d1393adf7108b2299cd7e69856c51852d037ad674d0d4d220a9

See more details on using hashes here.

Provenance

The following attestation bundles were made for ssrfguard-0.1.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

0.2.0

2 files

This release

0.1.0 This release

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