September 16, 2026
Four SSRF Bypasses in Four Months
Why Your Blocklist Was Never Going to Work

By Mostafa Moradian
12 min read
Update: I wrote this article back in May. The Go binding (with WASM) has a native port in pure Go (no WASM). The APIs are all the same, yet the native port is arguably faster. It's available under go-native/ressrf.
In February 2026, the Craft CMS team shipped a patch for an SSRF vulnerability. The fix was textbook: resolve the hostname, check it against a blocklist of cloud metadata IPs, reject if it matches. Done. Safe.
On the same day, two independent bypasses dropped against that patch. One abused DNS rebinding to make the same hostname resolve to different IPs at different times. The other exploited the fact that the validator only understood IPv4, while AWS had an IPv6 metadata endpoint all along.
This is not a story about Craft CMS specifically. Craft did what most applications do. The problem is that "resolve, check, request" is a fundamentally broken pattern, and it breaks in at least four distinct ways. Each way has a real CVE from the first half of 2026. And each one maps to a structural defense that a purpose-built SSRF prevention library can provide.
This article walks through all four bypass classes using real advisories, shows why the common patches remain fragile, and demonstrates how ressrf eliminates each class at the architecture level.
The Magician's Trick: DNS Rebinding
CVE-2026โ27127 (Craft CMS, February 2026)
Picture a developer writing SSRF protection. They call gethostbyname($hostname), compare the result against a list of dangerous IPs, and reject the request if it matches. The code is clean. The test suite passes. It ships.
Meanwhile, somewhere on the internet, an attacker has set up a DNS server for evil.attacker.com. This server has one unusual behavior: it answers the first query with a safe IP (say, 1.2.3.4), and the second query with 169.254.169.254. The TTL is zero, so nothing caches.
Here is what happens when the application receives [http://evil.attacker.com/latest/meta-data/](http://evil.attacker.com/latest/meta-data/:):
Query 1 (validation): evil.attacker.com โ 1.2.3.4 โ looks safe
Query 2 (Guzzle/curl): evil.attacker.com โ 169.254.169.254 โ AWS IMDSQuery 1 (validation): evil.attacker.com โ 1.2.3.4 โ looks safe
Query 2 (Guzzle/curl): evil.attacker.com โ 169.254.169.254 โ AWS IMDSThe check passed. The request goes to the metadata service. The attacker now has IAM credentials.
This is a Time-of-Check-Time-of-Use (TOCTOU) race condition applied to DNS. The thing you checked is not the thing you connected to, because DNS gave you a different answer each time you asked.
Why the obvious fix is fragile
The advisory recommends CURLOPT_RESOLVE to pin the DNS resolution. That works for curl. It does not work for Guzzle in all configurations, for Python's requests library, for Go's net/http, or for any other HTTP client you might switch to next year. The fix is stapled to one client's API surface, and you have to remember it every time you make an HTTP request from user-supplied input.
How ressrf eliminates this
The core insight is simple: resolve once, validate what you resolved, then connect to those exact addresses. No second lookup ever happens.
// ressrf-tcp: SafeResolver resolves and validates in one step
pub async fn resolve(&self, host: &str, port: u16) -> Result<ResolveResult, TcpGuardError> {
let addrs = self.dns.resolve(host, port).await?;
let mut validated = Vec::with_capacity(addrs.len());
for addr in &addrs {
match self.policy.is_network_allowed(&[addr.ip()]) {
Ok(()) => validated.push(*addr),
Err(_) => { /* blocked, skip */ }
}
}
if validated.is_empty() {
return Err(TcpGuardError::AllBlocked { host: host.to_string() });
}
Ok(ResolveResult { addrs: validated, host: host.to_string() })
}// ressrf-tcp: SafeResolver resolves and validates in one step
pub async fn resolve(&self, host: &str, port: u16) -> Result<ResolveResult, TcpGuardError> {
let addrs = self.dns.resolve(host, port).await?;
let mut validated = Vec::with_capacity(addrs.len());
for addr in &addrs {
match self.policy.is_network_allowed(&[addr.ip()]) {
Ok(()) => validated.push(*addr),
Err(_) => { /* blocked, skip */ }
}
}
if validated.is_empty() {
return Err(TcpGuardError::AllBlocked { host: host.to_string() });
}
Ok(ResolveResult { addrs: validated, host: host.to_string() })
}The SafeConnector then connects directly to the validated SocketAddr:
// The connection uses the EXACT IP that was validated. No re-resolution.
pub async fn connect(&self, host: &str, port: u16) -> Result<TcpStream, TcpGuardError> {
let resolved = self.resolver.resolve(host, port).await?;
for addr in &resolved.addrs {
match self.connect_addr(*addr, start).await {
Ok(stream) => return Ok(stream),
Err(e) => { last_err = Some(e); }
}
}
Err(last_err.unwrap_or_else(|| TcpGuardError::AllBlocked { host: host.to_string() }))
}// The connection uses the EXACT IP that was validated. No re-resolution.
pub async fn connect(&self, host: &str, port: u16) -> Result<TcpStream, TcpGuardError> {
let resolved = self.resolver.resolve(host, port).await?;
for addr in &resolved.addrs {
match self.connect_addr(*addr, start).await {
Ok(stream) => return Ok(stream),
Err(e) => { last_err = Some(e); }
}
}
Err(last_err.unwrap_or_else(|| TcpGuardError::AllBlocked { host: host.to_string() }))
}There is no gap between validation and use. The DNS result is validated, and the validated IP is what the TCP handshake connects to. The magician has no window to swap cards.
The same pattern exists in every language binding:
// Go: SafeDialer uses net.Dialer.Control to validate the resolved IP
// at the OS socket level, after DNS but before connect().
dialer := policy.SafeDialer()
conn, err := dialer.DialContext(ctx, "tcp", "evil.attacker.com:80")
# Python: safe_getaddrinfo validates all resolved IPs before returning
from ressrf.protocols.tcp import safe_getaddrinfo
addrs = safe_getaddrinfo(policy, "evil.attacker.com", 80)// Go: SafeDialer uses net.Dialer.Control to validate the resolved IP
// at the OS socket level, after DNS but before connect().
dialer := policy.SafeDialer()
conn, err := dialer.DialContext(ctx, "tcp", "evil.attacker.com:80")
# Python: safe_getaddrinfo validates all resolved IPs before returning
from ressrf.protocols.tcp import safe_getaddrinfo
addrs = safe_getaddrinfo(policy, "evil.attacker.com", 80)The Address You Didn't Check: IPv6 Bypass
CVE-2026โ27129 (Craft CMS, February 2026)
Same application. Same week. Different bypass. This time the attacker does not need a custom DNS server. They just need a hostname that only has an AAAA record.
The validation code calls gethostbyname(). From PHP's own documentation: this function returns "the IPv4 address corresponding to a given Internet host name." If the hostname has no A record (only AAAA), it returns the hostname string unchanged. The blocklist comparison becomes:
in_array("fd00-ec2--254.sslip.io", ['169.254.169.254', '169.254.170.2', ...])
// false - a hostname string will never match an IP stringin_array("fd00-ec2--254.sslip.io", ['169.254.169.254', '169.254.170.2', ...])
// false - a hostname string will never match an IP stringValidation passes. Then Guzzle (which uses libcurl, which happily resolves AAAA records) connects to fd00:ec2::254, the IPv6 endpoint for AWS IMDS. Credentials stolen.
The broader pattern
IPv6 is just one variant of a larger problem: addresses have many representations, and if your validator only understands one, the attacker uses another.
0177.0.0.1โ octal for 127.0.0.10x7f000001โ hex for 127.0.0.1::ffff:169.254.169.254โ IPv4-mapped IPv6fd00:ec2::254โ native IPv6 for AWS IMDS- Decimal encoding:
2852039166for 169.254.169.254
If your validator speaks one dialect and your network stack speaks another, the attacker speaks both.
How ressrf eliminates this
The CIDR engine normalizes every address into a single 128-bit representation before any comparison happens. IPv4 addresses are mapped to IPv6 with a +96 prefix adjustment. Octal and hex representations are rejected at parse time.
/// Normalize an IP to IPv6, adjusting the prefix length accordingly.
/// IPv4 addresses get mapped to ::ffff:a.b.c.d with prefix += 96.
fn normalize_to_ipv6(ip: IpAddr, prefix_len: u8) -> Result<(Ipv6Addr, u8)> {
match ip {
IpAddr::V4(v4) => {
let mapped = v4.to_ipv6_mapped();
let adjusted = prefix_len + 96;
Ok((mapped, adjusted))
}
IpAddr::V6(v6) => Ok((v6, prefix_len)),
}
}/// Normalize an IP to IPv6, adjusting the prefix length accordingly.
/// IPv4 addresses get mapped to ::ffff:a.b.c.d with prefix += 96.
fn normalize_to_ipv6(ip: IpAddr, prefix_len: u8) -> Result<(Ipv6Addr, u8)> {
match ip {
IpAddr::V4(v4) => {
let mapped = v4.to_ipv6_mapped();
let adjusted = prefix_len + 96;
Ok((mapped, adjusted))
}
IpAddr::V6(v6) => Ok((v6, prefix_len)),
}
}The containment check also normalizes the input:
pub fn contains(&self, ip: IpAddr) -> bool {
let normalized = match ip {
IpAddr::V4(v4) => v4.to_ipv6_mapped(),
IpAddr::V6(v6) => v6,
};
// Bitwise comparison against precomputed network + mask
normalized.octets().iter()
.zip(self.mask.iter())
.zip(self.network.iter())
.all(|((&octet, &mask), &net)| (octet & mask) == net)
}pub fn contains(&self, ip: IpAddr) -> bool {
let normalized = match ip {
IpAddr::V4(v4) => v4.to_ipv6_mapped(),
IpAddr::V6(v6) => v6,
};
// Bitwise comparison against precomputed network + mask
normalized.octets().iter()
.zip(self.mask.iter())
.zip(self.network.iter())
.all(|((&octet, &mask), &net)| (octet & mask) == net)
}Whether the attacker supplies 169.254.169.254 or ::ffff:169.254.169.254, the CIDR engine normalizes both to the same 128-bit representation and the deny list catches them. And fd00:ec2::254 is caught by the cloud module's explicit IPv6 deny range. No representation escapes validation.
And for the octal/hex tricks:
/// Reject IPv4 addresses with leading zeros (octal) or hex prefixes.
fn reject_ambiguous_ipv4(s: &str) -> Result<()> {
for octet_str in s.split('.') {
if octet_str.is_empty() {
return Err(Error::Parse("empty octet in IPv4 address".into()));
}
if octet_str.starts_with("0x") || octet_str.starts_with("0X") {
return Err(Error::Parse("hex notation not allowed in IP addresses".into()));
}
if octet_str.len() > 1 && octet_str.starts_with('0') {
return Err(Error::Parse("leading zeros (octal notation) not allowed".into()));
}
}
Ok(())
}/// Reject IPv4 addresses with leading zeros (octal) or hex prefixes.
fn reject_ambiguous_ipv4(s: &str) -> Result<()> {
for octet_str in s.split('.') {
if octet_str.is_empty() {
return Err(Error::Parse("empty octet in IPv4 address".into()));
}
if octet_str.starts_with("0x") || octet_str.starts_with("0X") {
return Err(Error::Parse("hex notation not allowed in IP addresses".into()));
}
if octet_str.len() > 1 && octet_str.starts_with('0') {
return Err(Error::Parse("leading zeros (octal notation) not allowed".into()));
}
}
Ok(())
}The AWS cloud module explicitly includes the IPv6 IMDS endpoint in its deny list:
{
"deny_ranges": [
{ "cidr": "169.254.169.254/32", "name": "IMDS (IPv4)" },
{ "cidr": "fd00:ec2::254/128", "name": "IMDSv2 (IPv6)" },
{ "cidr": "169.254.170.2/32", "name": "ECS task metadata" }
]
}{
"deny_ranges": [
{ "cidr": "169.254.169.254/32", "name": "IMDS (IPv4)" },
{ "cidr": "fd00:ec2::254/128", "name": "IMDSv2 (IPv6)" },
{ "cidr": "169.254.170.2/32", "name": "ECS task metadata" }
]
}The DNS resolution layer (tokio::net::lookup_host or hickory-resolver) returns both A and AAAA records. Every returned IP is validated. There is no "IPv4-only glasses" problem because the library does not split resolution by address family.
Following the Breadcrumbs: Redirect Chains
CVE-2026โ5921 (GitHub Enterprise Server, April 2026, CVSS 9.5)
This one is different. The initial URL is perfectly safe. The vulnerability is not in what you request, but in where it takes you.
GitHub Enterprise Server's notebook rendering service fetched external resources to display them inline. It checked the initial URL. Then it followed HTTP redirects without re-checking the destination. An attacker could host a notebook that referenced a URL on their server, which returned a 302 redirect to an internal service. The renderer dutifully followed it.
But it gets worse. The attacker could not directly read the response from internal services (the content was rendered, not returned raw). So they used a timing side-channel: they pointed the redirect at an internal regex-based API, crafted queries that would match or not match specific characters of secret values, and measured response time differences. Character by character, they extracted sensitive environment variables.
The fix seems obvious: just re-check after redirects. But in practice, HTTP clients abstract away redirect following. You hand a URL to requests.get() or http.Get() and get back the final response. The intermediate hops are invisible unless you specifically opt in to intercepting them.
How ressrf eliminates this
The RedirectValidator sits in the redirect chain and runs every hop through the full SSRF policy:
pub fn validate_hop(&self, target_url: &str) -> Result<(), HttpGuardError> {
let hop = self.hops.fetch_add(1, Ordering::Relaxed);
if hop >= self.redirect_policy.max_redirects {
return Err(HttpGuardError::TooManyRedirects { max: self.redirect_policy.max_redirects });
}
// Full URI validation against the policy on EVERY hop
let validator = ressrf_core::UriValidator::default();
if let Err(_) = validator.validate_url(target_url, Some(&self.policy)) {
return Err(HttpGuardError::RedirectBlocked { hop: hop + 1, url: target_url.to_string() });
}
// Scheme downgrade protection
if let Some(scheme_end) = target_url.find("://") {
let scheme = &target_url[..scheme_end];
if self.policy.validate_scheme(scheme).is_err() {
return Err(HttpGuardError::RedirectBlocked { hop: hop + 1, url: target_url.to_string() });
}
}
Ok(())
}pub fn validate_hop(&self, target_url: &str) -> Result<(), HttpGuardError> {
let hop = self.hops.fetch_add(1, Ordering::Relaxed);
if hop >= self.redirect_policy.max_redirects {
return Err(HttpGuardError::TooManyRedirects { max: self.redirect_policy.max_redirects });
}
// Full URI validation against the policy on EVERY hop
let validator = ressrf_core::UriValidator::default();
if let Err(_) = validator.validate_url(target_url, Some(&self.policy)) {
return Err(HttpGuardError::RedirectBlocked { hop: hop + 1, url: target_url.to_string() });
}
// Scheme downgrade protection
if let Some(scheme_end) = target_url.find("://") {
let scheme = &target_url[..scheme_end];
if self.policy.validate_scheme(scheme).is_err() {
return Err(HttpGuardError::RedirectBlocked { hop: hop + 1, url: target_url.to_string() });
}
}
Ok(())
}This integrates with HTTP clients through their redirect hooks:
// Rust (reqwest)
use std::sync::Arc;
let policy = Arc::new(policy);
let validator = RedirectValidator::new(policy, RedirectPolicy::follow(10));
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::custom(move |attempt| {
match validator.validate_hop(attempt.url().as_str()) {
Ok(()) => attempt.follow(),
Err(_) => attempt.stop(),
}
}))
.build()?;
// Go: HTTPClient combines transport-layer IP validation with CheckRedirect
client := policy.HTTPClient(http.DefaultTransport)
# Python: httpx SafeTransport re-validates every redirect
from ressrf import Policy
from ressrf.protocols.http import SafeTransport
policy = Policy.external_only(cloud=["aws"])
client = httpx.Client(transport=SafeTransport(policy))
// Node.js: safeFetch re-validates redirects automatically
import { Policy, http } from "ressrf";
const policy = await Policy.externalOnly({ cloud: ["aws"] });
const response = await http.safeFetch("https://external.com/resource", { policy });// Rust (reqwest)
use std::sync::Arc;
let policy = Arc::new(policy);
let validator = RedirectValidator::new(policy, RedirectPolicy::follow(10));
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::custom(move |attempt| {
match validator.validate_hop(attempt.url().as_str()) {
Ok(()) => attempt.follow(),
Err(_) => attempt.stop(),
}
}))
.build()?;
// Go: HTTPClient combines transport-layer IP validation with CheckRedirect
client := policy.HTTPClient(http.DefaultTransport)
# Python: httpx SafeTransport re-validates every redirect
from ressrf import Policy
from ressrf.protocols.http import SafeTransport
policy = Policy.external_only(cloud=["aws"])
client = httpx.Client(transport=SafeTransport(policy))
// Node.js: safeFetch re-validates redirects automatically
import { Policy, http } from "ressrf";
const policy = await Policy.externalOnly({ cloud: ["aws"] });
const response = await http.safeFetch("https://external.com/resource", { policy });The redirect chain from the GitHub advisory would die at hop 2: the initial URL passes (it points to the attacker's server), but the 302 target resolves to an internal IP, and the validator blocks it. The timing side-channel never gets a chance to operate because the connection to the internal service is never established.
Two Parsers, One URL: Parsing Inconsistency
CVE-2026โ25960 (vLLM, 2026, CVSS 7.1)
This is the most subtle bypass of the four, and perhaps the most instructive about why SSRF prevention cannot be bolted on as a separate layer.
vLLM's load_from_url_async method validates URLs before fetching them. The validation uses urllib3.util.parse_url() to extract the hostname, checks it against an allowlist, and if it passes, hands the URL to aiohttp for the actual HTTP request. Here is the problem: aiohttp uses yarl internally, and urllib3 and yarl disagree about backslashes.
Given the URL [https://httpbin.org\@evil.com/](https://httpbin.org\@evil.com/:):
- urllib3 treats \ as a literal character in the path. It URL-encodes it to
%5C. The hostname ishttpbin.org. Validation passes. - yarl (used by aiohttp) treats @ as a userinfo separator. The hostname becomes
evil.com. The request goes to the attacker.
Same bytes. Two parsers. Two different interpretations. The attacker finds the input where they disagree.
Why this keeps happening
This is not a bug in either parser. Both are following their own internally consistent logic. The vulnerability exists in the gap between them: the application uses one parser to decide if a URL is safe, and a different parser to decide where to send the request.
This is the classic "two kings in one kingdom" problem applied to URL parsing. Two authorities (urllib3 and yarl) both claim sovereignty over the same input. When they disagree, the attacker is the only one who benefits.
This pattern is everywhere. Any time validation and execution parse the same input independently, you have created a surface for parser differential attacks.
How ressrf eliminates this
ressrf's answer is architectural: do not have two parsers. The policy is not a pre-flight check on a URL string that then gets handed to a separate HTTP client. The policy integrates at the transport layer itself: at DNS resolution time.
The first line of defense is normalization. Before the URI validator even extracts the authority, it replaces backslashes with forward slashes in the post-scheme portion of the URL:
// BS-5: backslash -> slash normalization in the post-scheme part only.
let (scheme, rest_owned) = if let Some(idx) = url.find("://") {
let scheme = &url[..idx];
let rest = &url[idx + 3..];
let rest_normalized = rest.replace('\\', "/");
(Some(String::from(scheme)), rest_normalized)
} else {
(None, url.replace('\\', "/"))
};// BS-5: backslash -> slash normalization in the post-scheme part only.
let (scheme, rest_owned) = if let Some(idx) = url.find("://") {
let scheme = &url[..idx];
let rest = &url[idx + 3..];
let rest_normalized = rest.replace('\\', "/");
(Some(String::from(scheme)), rest_normalized)
} else {
(None, url.replace('\\', "/"))
};Given the vLLM payload https://httpbin.org\@evil.com/, the post-scheme portion httpbin.org\@evil.com/ becomes httpbin.org/@evil.com/. The host is now unambiguously httpbin.org, and /@evil.com/ is the path. There is no parser to disagree with because the ambiguity is eliminated before parsing begins.
The second line of defense catches userinfo-based bypass attempts that survive normalization:
/// Detect userinfo bypass attempts: URL-encoded @ (%40) before the real host.
fn has_userinfo_bypass(url: &str) -> bool {
let rest = if let Some(idx) = url.find("://") { &url[idx + 3..] } else { url };
let authority = rest.split('/').next().unwrap_or(rest);
let authority = authority.split('?').next().unwrap_or(authority);
let authority = authority.split('#').next().unwrap_or(authority);
// If there is a real @ AND a %40 in the authority, this is suspicious
if authority.contains('@') && authority.contains("%40") {
return true;
}
// If there is a %40 that could be interpreted as a host separator
if let Some(encoded_at_pos) = authority.find("%40") {
let after = &authority[encoded_at_pos + 3..];
let after_host = if let Some(at_idx) = after.rfind('@') {
&after[at_idx + 1..] } else { after };
if after_host.contains('.') || after_host.contains(':') {
return true;
}
}
false
}/// Detect userinfo bypass attempts: URL-encoded @ (%40) before the real host.
fn has_userinfo_bypass(url: &str) -> bool {
let rest = if let Some(idx) = url.find("://") { &url[idx + 3..] } else { url };
let authority = rest.split('/').next().unwrap_or(rest);
let authority = authority.split('?').next().unwrap_or(authority);
let authority = authority.split('#').next().unwrap_or(authority);
// If there is a real @ AND a %40 in the authority, this is suspicious
if authority.contains('@') && authority.contains("%40") {
return true;
}
// If there is a %40 that could be interpreted as a host separator
if let Some(encoded_at_pos) = authority.find("%40") {
let after = &authority[encoded_at_pos + 3..];
let after_host = if let Some(at_idx) = after.rfind('@') {
&after[at_idx + 1..] } else { after };
if after_host.contains('.') || after_host.contains(':') {
return true;
}
}
false
}But the deepest defense is architectural. The protocol adapters do not work as URL-string middleware. They integrate at the DNS and connection layer. The HTTP adapter validates the resolved IP of whatever hostname the HTTP client actually connects to. It does not matter what a string parser thinks the hostname is, because the validation happens on the actual network address the OS is about to dial.
In the vLLM scenario, even if a URL somehow confused the string-level validator, the protocol adapter would still catch the connection to evil.com at the DNS resolution step. If evil.com resolved to a blocked IP, it would be blocked. If it resolved to a safe IP, you are connecting to a safe IP regardless. The parser differential becomes irrelevant because validation happens on the resolved address, not on the parser's interpretation of the URL string.
The Pattern Behind the Patches
Step back from the four stories and a single pattern emerges. Every bypass exploits a gap between what was checked and what was used:
Ad-hoc fixes try to close each gap individually: pin DNS with CURLOPT_RESOLVE, add AAAA record checks, intercept redirects, normalize URLs. Each fix is specific to one client, one language, one vulnerability. Next quarter a new gap opens.
As 0xdade put it: "Security is the side-effect of good engineering." Not a product you bolt on, not a regex you prepend to a request handler. It is what falls out naturally when the system is designed so that the unsafe state is unrepresentable. SSRF prevention is no different. You do not solve it by adding more checks. You solve it by removing the gap between checking and connecting.
ressrf closes these gaps by refusing to have them:
- No second DNS lookup. Resolve once, validate, connect to the validated address.
- One address representation. Everything becomes 128-bit IPv6 before comparison. Octal and hex are rejected at parse time.
- Re-validate every hop. The redirect interceptor runs the full policy on each target.
- Be the resolver. The policy operates at the transport layer, not as a string pre-check.
These are not four separate features. They are four consequences of one design decision: the library sits between your application and the network, validating the actual destination at the moment of connection, not a string representation of what you hope the destination will be.
What This Means for You
If you are building an application that fetches URLs on behalf of users, you are playing a game with four categories of adversary input and a growing list of bypasses. You can keep patching each one as it arrives, or you can move the validation to a layer where the bypasses stop working.
The four CVEs in this article are not outliers. They are the predictable result of a pattern that most applications follow: validate a string, then hand that string to a separate system that re-parses it. Every seam between validation and execution is a surface the attacker can exploit.
ressrf was built to remove those seams. It is not a blocklist. It is not a regex. It is a policy engine that lives inside your HTTP client's DNS resolver and connection layer, validating the actual network destination at the moment the socket opens. DNS rebinding, address encoding tricks, redirect chains, and parser differentials all become irrelevant when the thing you validate is the thing you connect to.
Getting Started
Integration takes a few lines in any of the four supported languages:
// Rust
use ressrf_core::{PolicyBuilder, CloudProvider};
use ressrf_tcp::SafeConnector;
let mut builder = PolicyBuilder::external_only();
builder.with_cloud(CloudProvider::Aws)
.with_cloud(CloudProvider::Azure)
.with_cloud(CloudProvider::Gcp);
let policy = builder.build();
let connector = SafeConnector::new(policy);
let stream = connector.connect("example.com", 443).await?;
// Go
policy, _ := ressrf.NewPolicyBuilder(ressrf.PresetExternalOnly).
WithCloudProviders("aws", "azure", "gcp").
Build(ctx)
defer policy.Close(ctx)
client := policy.HTTPClient(http.DefaultTransport)
# Python
from ressrf import Policy
from ressrf.protocols.http import SafeTransport
policy = Policy.external_only(cloud=["aws", "azure", "gcp"])
client = httpx.Client(transport=SafeTransport(policy))
// Node.js
import { Policy, http } from "ressrf";
const policy = await Policy.externalOnly({ cloud: ["aws", "azure", "gcp"] });
const res = await http.safeFetch(url, { policy });// Rust
use ressrf_core::{PolicyBuilder, CloudProvider};
use ressrf_tcp::SafeConnector;
let mut builder = PolicyBuilder::external_only();
builder.with_cloud(CloudProvider::Aws)
.with_cloud(CloudProvider::Azure)
.with_cloud(CloudProvider::Gcp);
let policy = builder.build();
let connector = SafeConnector::new(policy);
let stream = connector.connect("example.com", 443).await?;
// Go
policy, _ := ressrf.NewPolicyBuilder(ressrf.PresetExternalOnly).
WithCloudProviders("aws", "azure", "gcp").
Build(ctx)
defer policy.Close(ctx)
client := policy.HTTPClient(http.DefaultTransport)
# Python
from ressrf import Policy
from ressrf.protocols.http import SafeTransport
policy = Policy.external_only(cloud=["aws", "azure", "gcp"])
client = httpx.Client(transport=SafeTransport(policy))
// Node.js
import { Policy, http } from "ressrf";
const policy = await Policy.externalOnly({ cloud: ["aws", "azure", "gcp"] });
const res = await http.safeFetch(url, { policy });The ExternalOnly preset blocks all IANA special-purpose ranges, cloud metadata endpoints, and link-local addresses out of the box. Cloud modules add provider-specific deny ranges (AWS IMDS/ECS IPv4 and IPv6, Azure IMDS and Wireserver, GCP metadata). You can punch holes with allow lists for internal services you trust, or add URL rules for fine-grained path-level control.
The Rust core is fuzz-tested weekly with three cargo-fuzz targets. All four language bindings run the same shared test vectors in CI, so a bypass that works in one language would fail the conformance suite in every other. The default deny list is sourced from IANA special-purpose registries and refreshed monthly via an automated workflow.
Acknowledgments
ressrf did not emerge in a vacuum. Several existing projects shaped its design and provided test data:
- Microsoft's AntiSSRF pioneered the two-layer architecture of policy engine plus transport integration that ressrf follows. Its IP range data and test suite seeded ressrf's initial test vectors.
- Stripe's Smokescreen demonstrated that an egress proxy with SSRF-aware policy is viable at scale, and serves as the reference architecture for ressrf's upcoming standalone proxy mode.
- Arcjet proved the Rust-core-compiled-to-WASM architecture in production, validating that a single source of truth for security logic can serve Go, JavaScript, and other runtimes without behavioral drift.
These projects and many other libraries in different programming languages showed that the problem was solvable. ressrf's contribution is combining their insights into a single fuzz-tested library with bindings for four languages and a deny-first architecture that addresses all four bypass classes discussed in this article.
Contribution
If you would like to see integration with your favorite programming language, cloud provider, protocol, or library, feel free to open an issue or submit a pull request. Contributions of all kinds are welcome.
Further Reading
- Source code and documentation: github.com/mostafa/ressrf
- CVE-2026โ27127 (DNS rebinding): GHSA-gp2f-7wcm-5fhx
- CVE-2026โ27129 (IPv6 bypass): GHSA-v2gc-rm6g-wrw9
- CVE-2026โ5921 (redirect chain): Tenable
- CVE-2026โ25960 (URL parsing): GHSA-v359-jj2v-j536