Skip to main content
SecurityAPI DevelopmentLaravelWebhooksSSRF

SSRF Protection for URL Imports and Webhooks: What Actually Works

ZsTechLabs Team·September 18, 2026·8 min read

Any feature that takes a URL from a user and makes your server request it needs SSRF protection. "Import an image from a link" and "notify this webhook when the job finishes" both count. Server-side request forgery turns your server into the attacker's proxy. It sits inside your network, so it can reach addresses the attacker can't: loopback services, private ranges, and the cloud metadata endpoint at 169.254.169.254, which hands out instance credentials. We've built SSRF guards into two of our own products: Snipinsta, which imports media from user-supplied URLs and posts to task webhooks, and Knownbase, which delivers signed webhooks to customer endpoints. This post covers what those guards check and why each check is there.

How the gap was found

In Snipinsta's case, an internal security audit found the pattern. URL imports and the processing-task webhook sent requests wherever the caller pointed them, and the webhook path was open to guests. The audit's probe reached a loopback port from the importer and stored a loopback webhook destination that a background worker would later POST to. Both paths were fixed, and the fix, PublicHttpEgress, is the model for the snippets below. It's now the single egress policy for every user-supplied URL the Laravel app fetches or posts to.

Why url validation doesn't help

Laravel's url rule, like most URL validators, checks that a string is shaped like a URL. As the comment on our replacement rule puts it, url on its own "accepts http://127.0.0.1:6379/ and http://169.254.169.254/ quite happily." Those are perfectly well-formed URLs. The question SSRF protection has to answer is where the URL points, and you can only answer that by resolving it.

So the form rule became a wrapper around the real policy:

'webhook_url' => ['nullable', 'url', 'max:2048', new PublicHttpUrl()],

url still does the shape check. PublicHttpUrl runs the egress policy, so a bad target is refused at the form with a readable message.

Block the easy things before DNS

Some URLs are wrong without resolving anything, and the policy refuses them first:

  • Non-http schemes. Only http and https pass. Tests pin file:///etc/passwd and gopher:// as refused.
  • Embedded credentials. http://user:pass@host/ is rejected. The code comment explains why: "the part before @ looks like the destination and is not."
  • Non-standard ports. Only 80 and 443 are allowed. In the code's words: "Anything else (21, 25, 6379, 11211, 9200 …) is a service, not a website, and nothing a user pastes as an image URL needs one."

That last rule also takes away a lot of what makes SSRF useful. Internal databases, caches and search clusters listen on their own ports, not on 443.

Check every resolved address, not the first

A hostname can return several A and AAAA records. If you check the first answer and then hand the hostname to your HTTP client, the client is free to connect to a different one. A DNS record that answers with a public IP and 127.0.0.1 is the classic bypass. The PHP guard collects every A and AAAA record and requires all of them to be public:

foreach ($addresses as $address) {
    if (! filter_var($address, FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
        throw new BlockedEgressException(
            'That URL points to a network address that is not allowed.');
    }
}

FILTER_FLAG_NO_PRIV_RANGE covers RFC 1918 and IPv6 unique-local addresses. FILTER_FLAG_NO_RES_RANGE covers loopback, link-local (including the metadata address) and the reserved blocks. IP literals are handled explicitly: the code notes that gethostbynamel returns false for one, which would otherwise let it through as "unresolvable."

Knownbase does the same in Node, resolving with dnsLookup(hostname, { all: true, verbatim: true }) and rejecting the URL if any returned address is disallowed. Its IPv6 handling taught us something. Our own security audit found that the IPv6 check recognised IPv4 embedded in the ::ffff: mapped form but missed the deprecated ::a.b.c.d compatible form and the NAT64 prefix 64:ff9b::/96. That meant https://[::169.254.169.254]/hook passed as safe. The fix expands every IPv6 literal to its canonical eight-group form before range-checking, and it refuses all three embedded-IPv4 spellings. If you write your own range checks, test the odd spellings, not just ::1.

Pin the connection to the address you checked

Validating a hostname and then letting the HTTP client resolve it again leaves a gap. DNS can answer differently the second time, which is the DNS-rebinding window. The fix is to connect to the exact address you approved. In PHP with curl, that's CURLOPT_RESOLVE:

final class PinnedTarget
{
    /** HOST:PORT:ADDRESS entries for CURLOPT_RESOLVE */
    public function curlResolveEntries(): array
    {
        return array_map(
            fn (string $ip) => sprintf('%s:%d:%s', $this->host, $this->port, $ip),
            $this->addresses,
        );
    }
}

Http::withoutRedirecting()
    ->withOptions(['curl' => [CURLOPT_RESOLVE => $target->curlResolveEntries()]])
    ->send($method, $target->url);

The important property is what this doesn't change. The request still goes to the original URL, so the Host header and TLS SNI still carry the real hostname, and certificate verification works normally. Only the address curl dials is fixed. Pinning by rewriting the URL to a bare IP would break HTTPS for every virtual-hosted site.

Knownbase gets the same effect in Node. It resolves and validates the address, then opens the request to that IP with servername set to the original hostname for TLS and an explicit host header. As the code comment says, calling fetch(hook.url) "would resolve the hostname a second time and leave a DNS-rebinding gap."

Re-validate redirects instead of following them

A URL that passes every check above can still answer 302 Location: http://127.0.0.1/. If your client follows redirects automatically, that one response undoes everything. Snipinsta turns automatic following off, and each Location goes back through the full policy:

$location = $response->header('Location');
$target = self::assertPublic(self::absolute($target->url, $location));

The loop is capped at three hops, and a redirect to another public address still works. Tests pin both cases, plus a redirect loop. Knownbase is stricter for webhooks: a 3xx from a webhook endpoint is recorded as a failed delivery and never followed. Its audit had found that the earlier fetch() call used default redirect-following, which let a destination that passed the check bounce the request anywhere. For webhooks, refusing redirects is a reasonable default. A legitimate receiver has no reason to send one.

Keep error messages vague

Every refusal that involves an address returns the same sentence: "That URL points to a network address that is not allowed." The code comment explains why: "confirming which address was refused would report back what exists on the internal network." Detailed errors ("port 6379 refused", "connection timed out") turn your import form into a port scanner that reports its results politely. Log the detail server-side if you need it, and give the user one generic answer.

Validate at submission and at delivery

Webhooks make timing matter. A URL is stored today and requested hours later, and DNS can change in between. Both products check twice:

  • At submission. Snipinsta's PublicHttpUrl rule runs on the task's webhook_url field, and Knownbase's createWebhook() refuses an unsafe URL before storing it. This check gives the user a clear error.
  • At delivery. Snipinsta's background job posts through PublicHttpEgress::request(), which validates, pins and re-checks redirects again. Knownbase's deliverWebhook() re-resolves and re-validates on every delivery.

The rule's own docblock is explicit about which one matters: "This rule is the good error message; the policy inside PublicHttpEgress::request is the actual boundary, because DNS can change between the two." If you only have budget for one check, keep the one at delivery.

The same policy covers Snipinsta's FastAPI service. remote_fetch_security.py enforces the http/https-only, no-credentials, ports 80/443 rules, requires every getaddrinfo answer to be is_global, and re-validates each redirect with allow_redirects=False.

Test it without the internet

SSRF tests that depend on real DNS are flaky and test the wrong thing. PublicHttpEgress has a resolver seam, resolveUsing(), so tests can stub DNS answers directly. The test suite covers loopback by literal and by name, private and link-local ranges, the metadata address, IPv6 loopback and unique-local, Redis's port, file:// and gopher://, embedded credentials, unresolvable hosts, a mixed public-plus-private answer, and redirects both to private and to public targets. A second boundary suite asserts that an internal import is refused without any request being sent, and that a guest can't point a task webhook at an internal address, while ordinary public URLs keep working.

The network is the other half

Application checks are one layer. The egress class says it plainly: "The network should independently deny egress to internal ranges; this is the application half, not a substitute for that." Firewall rules or security groups that stop your app servers from reaching metadata endpoints and internal subnets will still hold if a new code path forgets to use the guard. Do both.

Building something that fetches URLs?

Import-by-URL, link previews, webhook delivery and integrations all share this attack surface. It's cheapest to close while you're designing the feature. Our API development team builds these features with SSRF protection and webhook security designed in from the start. Get in touch if you'd like us to review how your app handles outbound requests today.