DEV Community

Cover image for Letting a Stranger's URL Into Your Server Without Building an SSRF Vector
Sara Casciaro
Sara Casciaro

Posted on

Letting a Stranger's URL Into Your Server Without Building an SSRF Vector

Any feature that fetches a URL supplied by an untrusted user is a Server-Side Request Forgery surface until proven otherwise. It does not matter how the feature is framed. "Check my website", "preview this link", "analyse this page": the mechanism underneath is identical. Your server makes an outbound HTTP request to an address someone else picked, from inside your network, with your server's identity and your server's network access.

This guide walks through the approach I used to ship exactly that kind of feature, a visitor-facing tool where anyone can type a URL into a chat interface and get a technical report back, and the specific bypasses my first implementation let through before I found them.

Why This Is a Textbook Attack Surface

The naive version is one line of code and a genuine liability:

// Don't do this.
$response = wp_remote_get( $user_supplied_url );
Enter fullscreen mode Exit fullscreen mode

The server making that request usually sits inside infrastructure with network access a public visitor should never get to borrow. The classic exploitation path targets cloud metadata endpoints, 169.254.169.254 on AWS, GCP and Azure, which can hand out instance credentials, IAM role tokens or user-data scripts to whoever crafted the URL. The more mundane path is just as real: an attacker uses your server as a probe against your own internal network, hitting localhost services, internal admin panels or database ports that were never meant to face the internet, and reads the response through whatever your feature echoes back.

Blocking the literal string localhost and calling it done is the first mistake almost everyone makes, because the number of ways to spell "internal address" is much larger than it looks.

The Ways an Address Lies About What It Is

A filter that only checks for localhost or 127.0.0.1 misses most of the real surface. Here is what actually needs covering.

Alternate numeric forms of loopback. 127.0.0.1 is not the only spelling. Decimal 2130706433, hex 0x7f000001 and octal 0177.0.0.1 all resolve to the same machine, and a string-matching filter catches none of them.

IPv6, including the IPv4-mapped and IPv4-compatible forms. ::1 is loopback. ::ffff:127.0.0.1 is an IPv4 address wearing an IPv6 costume. So is ::ffff:7f00:1, which is the same address written in hex, and ::127.0.0.1, the compatible form. I know these get through because I tried them: all of those passed my first implementation, along with the bare ::.

Link-local ranges. 169.254.0.0/16 in IPv4 and fe80::/10 in IPv6. This is the range containing the cloud metadata endpoint, which makes it the single highest-value block on the entire list.

The full private and reserved set, not just the three you remember. 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16, plus 0.0.0.0/8 and the carrier-grade NAT range 100.64.0.0/10 that is easy to forget because it is less commonly taught. And while you are there: 192.0.0.0/24, the benchmarking range 198.18.0.0/15, multicast 224.0.0.0/4 and reserved 240.0.0.0/4. None of those should ever be a website someone is asking you to audit.

DNS rebinding. A domain can resolve to a public IP at validation time and a private one at request time, if validation and the actual request each resolve the hostname independently.

Compare Bytes, Not Strings

The change that made the difference was giving up on string comparison entirely. Every address, whatever notation it arrived in, gets reduced to its raw bytes with inet_pton() before anything is decided about it.

public static function ip_in_cidr( $ip, $cidr ) {
    // ::ffff:1.2.3.4 is an IPv4 address in disguise. Unwrap it first.
    if ( 0 === stripos( $ip, '::ffff:' )
         && filter_var( substr( $ip, 7 ), FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
        $ip = substr( $ip, 7 );
    }

    list( $net, $bits ) = explode( '/', $cidr, 2 );
    $ip_bin  = @inet_pton( $ip );
    $net_bin = @inet_pton( $net );

    // Different address families, or unparseable input: not a match,
    // and the caller treats "not classified" as blocked.
    if ( false === $ip_bin || false === $net_bin
         || strlen( $ip_bin ) !== strlen( $net_bin ) ) {
        return false;
    }

    $whole = intdiv( (int) $bits, 8 );
    $rest  = (int) $bits % 8;

    if ( $whole > 0 && substr( $ip_bin, 0, $whole ) !== substr( $net_bin, 0, $whole ) ) {
        return false;
    }
    if ( 0 === $rest ) {
        return true;
    }

    $mask = chr( ( 0xFF << ( 8 - $rest ) ) & 0xFF );
    return ( $ip_bin[ $whole ] & $mask ) === ( $net_bin[ $whole ] & $mask );
}
Enter fullscreen mode Exit fullscreen mode

Because the comparison happens on bytes rather than text, decimal, octal and hex notations stop being special cases that need their own rules. They parse to the same integer, so they hit the same branch.

For IPv6 the same idea goes one level deeper: sixteen bytes, and decisions made on the first one or two.

$bin = @inet_pton( $ip );
if ( false === $bin || 16 !== strlen( $bin ) ) {
    return true; // cannot classify it, so block it
}

$zero = str_repeat( "\0", 16 );
if ( $bin === $zero || $bin === substr( $zero, 0, 15 ) . "\1" ) {
    return true; // :: and ::1
}

$b0 = ord( $bin[0] );
$b1 = ord( $bin[1] );

if ( 0xfe === $b0 && 0x80 === ( $b1 & 0xc0 ) ) return true; // fe80::/10 link-local
if ( 0xfc === ( $b0 & 0xfe ) )                  return true; // fc00::/7  unique-local
if ( 0xff === $b0 )                             return true; // ff00::/8  multicast

// IPv4 hiding inside IPv6 notation: both ::ffff:a.b.c.d and ::a.b.c.d.
// Pull out the real IPv4 and run the whole check again on it.
if ( str_repeat( "\0", 10 ) === substr( $bin, 0, 10 ) ) {
    $marker = substr( $bin, 10, 2 );
    if ( "\xff\xff" === $marker || "\0\0" === $marker ) {
        $v4 = unpack( 'N', substr( $bin, 12, 4 ) );
        return host_forbidden( 'http://' . long2ip( $v4[1] ) );
    }
}
Enter fullscreen mode Exit fullscreen mode

The recursive call in that last branch is the part I would defend hardest. An IPv4 address hidden inside IPv6 notation does not get a second, parallel set of rules that will inevitably drift out of sync with the first. It gets unwrapped and sent back through the front door.

Fail Closed, and Mean It

Every path that cannot classify an address confidently returns "blocked". No host in the URL: blocked. Hostname that does not resolve: blocked. Address that parses as neither IPv4 nor IPv6: blocked, and that is the final return of the function, not a fallthrough.

That last detail is worth stating explicitly, because it is where most implementations quietly invert. If the bottom of your validator returns "allowed" for anything that reached it without matching a rule, then every future edge case you have not thought of is allowed by default. Ending on "blocked" flips the polarity of your own ignorance.

The cost of that choice is real but cheap: a support ticket about a legitimate site that failed to resolve. The cost of the other choice only shows up when someone engineers the case you did not imagine, and it shows up silently.

Validate Every Hop, Not Just the First One

This is the part that makes everything above actually matter.

while ( true ) {
    if ( host_forbidden( $current_url ) ) {
        return error( 'blocked' );
    }

    $res = wp_safe_remote_get( $current_url, [
        'redirection'         => 0,     // follow redirects ourselves, deliberately
        'reject_unsafe_urls'  => true,  // WordPress's own validator, on top of ours
        'timeout'             => $remaining,
        'limit_response_size' => 5 * 1024 * 1024,
    ] );

    if ( is_wp_error( $res ) ) break;

    $code = (int) wp_remote_retrieve_response_code( $res );
    if ( $code < 300 || $code >= 400 ) break;

    $location = trim( (string) wp_remote_retrieve_header( $res, 'location' ) );
    if ( '' === $location || $hops_left <= 0 ) break;

    $current_url = WP_Http::make_absolute_url( $location, $current_url );
    $hops_left--;
}
Enter fullscreen mode Exit fullscreen mode

'redirection' => 0 is the line the whole design rests on. If you let the HTTP client follow redirects for you, every check you wrote applies to the first URL only, and the second, third and fourth requests in the chain never pass through your validator at all. A perfectly innocent-looking domain that answers 302 Location: http://169.254.169.254/latest/meta-data/ walks straight past a correct first check.

Following redirects yourself, in a loop that starts by re-validating, costs about six lines.

Two smaller things in that call are worth stealing. reject_unsafe_urls turns on WordPress's own URL validation, so you end up with two independent checks rather than betting everything on yours. And limit_response_size matters more than it looks: without it, a URL pointing at a ten-gigabyte file is a memory exhaustion bug wearing a link costume. I set it at 5 MB after finding that 2 MB was not enough for real-world pages, because a site built with a visual page builder inlines its stylesheets and scripts and goes past 2 MB without being remotely unusual.

Test the Bypasses, Not the Happy Path

The list that matters is not "does example.com work". It is every spelling of every forbidden range, run against the real function. This is the one I worked through by hand before shipping:

127.0.0.1                  plain loopback
127.1                      shortened form, still loopback
2130706433                 decimal
0x7f000001                 hexadecimal
0177.0.0.1                 octal
::1                        IPv6 loopback
::                         unspecified
::ffff:127.0.0.1           IPv4-mapped
::ffff:7f00:1              IPv4-mapped, written in hex
::127.0.0.1                IPv4-compatible
0:0:0:0:0:ffff:127.0.0.1   fully expanded mapped form
fe80::1                    IPv6 link-local
169.254.169.254            cloud metadata, highest-priority block
10.0.0.1                   private
172.16.0.1                 private
192.168.1.1                private
100.64.0.1                 carrier-grade NAT, commonly forgotten
Enter fullscreen mode Exit fullscreen mode

Several of those got through my first implementation. That is the reason this article exists: not because the final version is clever, but because the first one looked correct and was not. A validator earns trust by being run against this list and coming back with every line blocked, not by reading well on inspection.

Run the mirror list too, a set of confirmed-public addresses, and check that none of them are blocked. A filter aggressive enough to reject legitimate sites just teaches users to distrust the feature, which is a slower way of not having it at all.

What Is Still Imperfect

Two things I would rather say myself than have someone find.

Resolution happens twice. My validator resolves the hostname, and then the HTTP client resolves it again independently. Between those two moments, a hostile DNS server can change its answer. reject_unsafe_urls narrows the window because WordPress re-checks as well, but it does not close it. Closing it properly means resolving once and connecting to the resolved IP with the Host header set by hand, which in this environment costs more than it buys.

gethostbyname() only speaks IPv4. A hostname with only an AAAA record comes back unresolved, which means it gets blocked. Safe, and wrong: an IPv6-only site cannot be audited at all. It is the fail-closed trade-off doing exactly what it is designed to do, in one of the few cases where I would rather it did not.

Where This Runs

This validation sits underneath a chat tool that lets a website visitor ask an AI assistant to run a technical check on their own site. It is useful precisely because it accepts an arbitrary URL from someone you have never met, which is also precisely why it needed this much scrutiny before it could ship. The usefulness and the risk come from the same design decision.

You can validate your own implementation against Google's own list of reserved IPv4 ranges at IANA, which is the authoritative source and is worth reading once in full: there are more special-purpose ranges than most of us carry in our heads.

It is part of Sabriel AI, a WordPress assistant I built, where the site-audit tool runs this validator on every hop before a single byte of a stranger's website reaches my code. Happy to be told what I got wrong.


Written by Sara Casciaro, founder of Sabriel Agency, digital studio in Ugento (LE), Italy.

Top comments (0)